diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 634a2e66..9ac32403 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -20538,6 +20538,360 @@ "en": "No suitable PK/UNIQUE column was found in the selected table for the FK.", "tr": "Seçilen tabloda FK için uygun PK/UNIQUE kolon bulunamadı." }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Title", + "en": "View Designer", + "tr": "View Tasarımcısı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Description", + "en": "Pick sources, build joins, filters and aggregations, then deploy with T-SQL", + "tr": "Kaynakları seçin, join / filtre / özet tanımlayın ve T-SQL ile deploy edin" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.EditView", + "en": "Edit View", + "tr": "View Düzenle" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.SearchObjects", + "en": "Search tables and views", + "tr": "Tablo ve view ara" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.NoObjectsFound", + "en": "No matching object", + "tr": "Eşleşen nesne yok" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.PaneDiagram", + "en": "Diagram pane", + "tr": "Diyagram paneli" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.PaneCriteria", + "en": "Criteria pane", + "tr": "Kriter paneli" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.PaneSql", + "en": "SQL pane", + "tr": "SQL paneli" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.AddTable", + "en": "Add Table", + "tr": "Tablo Ekle" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.EmptyCanvasHint", + "en": "Add a table or view from the left panel, then drag from a column handle to another column to create a join.", + "tr": "Soldaki panelden tablo veya view ekleyin; join kurmak için bir kolonun tutamacından diğer kolona tıklayın." + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.PendingLinkHint", + "en": "Pick the column to join with (Esc / click empty area to cancel)", + "tr": "Bağlanacak kolonu seçin (Esc / boş alana tıklayarak iptal)" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ToggleAllColumns", + "en": "Select / clear all columns", + "tr": "Tüm kolonları seç / temizle" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ShowSelectedOnly", + "en": "Show only selected columns", + "tr": "Yalnızca seçili kolonları göster" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ShowAllColumns", + "en": "Show all columns", + "tr": "Tüm kolonları göster" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.JoinType", + "en": "Join type", + "tr": "Join tipi" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.RemoveJoin", + "en": "Remove join", + "tr": "Join'i kaldır" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.RemoveTable", + "en": "Remove table", + "tr": "Tabloyu kaldır" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.TableColumn", + "en": "Table", + "tr": "Tablo" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Output", + "en": "Output", + "tr": "Çıktı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.GroupByColumn", + "en": "Group By", + "tr": "Group By" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.SortType", + "en": "Sort Type", + "tr": "Sıralama Tipi" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.SortOrder", + "en": "Sort Order", + "tr": "Sıra No" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Filter", + "en": "Filter", + "tr": "Filtre" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.OrColumn", + "en": "Or...", + "tr": "Veya..." + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.AddRow", + "en": "Add row", + "tr": "Satır ekle" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.FilterBuilder", + "en": "Filter builder", + "tr": "Filtre yardımcısı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Apply", + "en": "Apply", + "tr": "Uygula" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.GroupByToggle", + "en": "Toggle Group By (Σ)", + "tr": "Group By'ı aç / kapat (Σ)" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ResizeBox", + "en": "Drag to resize", + "tr": "Sürükleyerek boyutlandır" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.AddExpression", + "en": "Add expression", + "tr": "İfade ekle" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ExpressionPlaceholder", + "en": "CASE WHEN … END", + "tr": "CASE WHEN … END" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.AliasLocked", + "en": "Alias is locked: free expressions or subqueries reference it by name", + "tr": "Takma ad kilitli: serbest ifadeler veya alt sorgular bu adı metin olarak kullanıyor" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationExpressionAlias", + "en": "Expression columns need an output name", + "tr": "İfade kolonlarına çıktı adı verilmelidir" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Alias", + "en": "Alias", + "tr": "Takma ad" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Column", + "en": "Column", + "tr": "Kolon" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.OutputAlias", + "en": "Output name", + "tr": "Çıktı adı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.NoColumnSelected", + "en": "No column selected yet", + "tr": "Henüz kolon seçilmedi" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Value", + "en": "Value", + "tr": "Değer" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValueModeLiteral", + "en": "Literal", + "tr": "Sabit değer" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValueModeExpression", + "en": "SQL expression", + "tr": "SQL ifadesi" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.Schema", + "en": "Schema", + "tr": "Şema" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ViewName", + "en": "View name", + "tr": "View adı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.NothingToGenerate", + "en": "Add at least one source and one column", + "tr": "En az bir kaynak ve bir kolon ekleyin" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.OpenInEditor", + "en": "Open in Query Editor", + "tr": "Query Editor'da aç" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.DeploySucceeded", + "en": "View deployed successfully", + "tr": "View başarıyla oluşturuldu" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.DeployFailed", + "en": "View could not be deployed", + "tr": "View oluşturulamadı" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ParseFailedTitle", + "en": "This view cannot be opened in the visual designer", + "tr": "Bu view görsel tasarımcıda açılamıyor" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ParseFailedDescription", + "en": "Its definition uses SQL the designer does not model. Continue in the Query Editor, or redesign it from scratch.", + "tr": "Tanımı tasarımcının modellemediği bir SQL yapısı içeriyor. Query Editor'dan devam edebilir ya da sıfırdan tasarlayabilirsiniz." + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.RedesignFromScratch", + "en": "Redesign from scratch", + "tr": "Sıfırdan tasarla" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationNoSource", + "en": "Add at least one source", + "tr": "En az bir kaynak ekleyin" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationAliasRequired", + "en": "alias is required", + "tr": "takma ad zorunlu" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationAliasDuplicate", + "en": "alias is used more than once", + "tr": "takma ad birden fazla kez kullanılmış" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationJoinRequired", + "en": "at least one join condition is required", + "tr": "en az bir join koşulu gerekli" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationNoColumn", + "en": "Select at least one column", + "tr": "En az bir kolon seçin" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationColumnAliasDuplicate", + "en": "output name is used more than once", + "tr": "çıktı adı birden fazla kez kullanılmış" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationViewNameRequired", + "en": "View name is required", + "tr": "View adı zorunludur" + }, + { + "resourceName": "Platform", + "key": "App.SqlViewDesigner.ValidationViewNameInvalid", + "en": "View name contains invalid characters", + "tr": "View adı geçersiz karakter içeriyor" + }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitTableDesigner.ColumnTemplates", + "en": "Column Templates", + "tr": "Kolon Şablonları" + }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitTableDesigner.ColumnTemplatesHint", + "en": "Pick a ready-made column set to append", + "tr": "Eklemek istediğiniz hazır kolon setini seçin" + }, { "resourceName": "Platform", "key": "App.DeveloperKitTableDesigner.OneToMany", diff --git a/configs/seeds/host/crud/.gitkeep b/configs/seeds/host/crud/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/custom/.gitkeep b/configs/seeds/host/custom/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/postgres/execute/.gitkeep b/configs/seeds/host/postgres/execute/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/postgres/object/.gitkeep b/configs/seeds/host/postgres/object/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/sql/execute/.gitkeep b/configs/seeds/host/sql/execute/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/sql/object/.gitkeep b/configs/seeds/host/sql/object/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/host/wizard/.gitkeep b/configs/seeds/host/wizard/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/configs/seeds/tenants/.gitkeep b/configs/seeds/tenants/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/ui/src/views/developerKit/SqlObjectExplorer.tsx b/ui/src/views/developerKit/SqlObjectExplorer.tsx index 43923883..e06e2d34 100644 --- a/ui/src/views/developerKit/SqlObjectExplorer.tsx +++ b/ui/src/views/developerKit/SqlObjectExplorer.tsx @@ -48,6 +48,10 @@ interface SqlObjectExplorerProps { onGenerateTableScript?: (schemaName: string, tableName: string) => void onDesignTable?: (schemaName: string, tableName: string) => void onNewTable?: () => void + /** Gorsel View Designer'i yeni view icin acar; verilmezse SQL sablonu kullanilir. */ + onNewView?: () => void + /** Mevcut bir view'i gorsel tasarimcida acar. */ + onDesignView?: (schemaName: string, objectName: string) => void onSelectedObjectsChange?: (objects: SqlExplorerSelectedObject[]) => void /** Entity adina gore acilmis CRUD endpoint'leri; tablo/view satirlarinda ikon olarak gosterilir. */ crudEndpointsByEntity?: Map @@ -89,6 +93,8 @@ const SqlObjectExplorer = ({ onGenerateTableScript, onDesignTable, onNewTable, + onNewView, + onDesignView, onSelectedObjectsChange, crudEndpointsByEntity, onManageCrudEndpoints, @@ -377,9 +383,7 @@ const SqlObjectExplorer = ({ if (dropped.length > 0) { // configs/seeds/SqlData altindaki eslesen seed dosyalari da silinir. try { - const fileNames = [ - ...new Set(dropped.flatMap(getSqlDataFileCandidates).filter(Boolean)), - ] + const fileNames = [...new Set(dropped.flatMap(getSqlDataFileCandidates).filter(Boolean))] if (fileNames.length > 0) { await sqlObjectManagerService.deleteSqlDataFiles({ fileNames }) } @@ -730,6 +734,21 @@ const SqlObjectExplorer = ({ )} + {isNativeObj && ctxNode!.folder === 'views' && onDesignView && ( + + )} + {ctxCrudObjectType && onManageCrudEndpoints && ( - +
+
+
+ + {templatesOpen && ( +
+
+ {translate('::App.DeveloperKitTableDesigner.ColumnTemplatesHint')} +
+
+ {columnTemplates.map((tpl) => ( + + ))} +
+
+ )}
-
- - - - -
-
-
+ +
+ +
+ + {columns.length} {translate('::App.SqlQueryManager.ColumnName')} + + @@ -2678,7 +2795,9 @@ const SqlTableDesignerDialog = ({
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 diff --git a/ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx b/ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx new file mode 100644 index 00000000..55289edf --- /dev/null +++ b/ui/src/views/developerKit/sqlViewDesigner/CriteriaGrid.tsx @@ -0,0 +1,472 @@ +/** + * View Designer — Criteria grid (SSMS Query Designer'in orta bolumu). + * + * Sutunlar: Column · Alias · Table · Output · Group By · Sort Type · Sort Order + * · Filter · Or... Filtre hucreleri serbest yuklemdir ("> 100", "LIKE '%a%'"); + * yaninda kucuk bir yardimci ile de doldurulabilir. + */ +import { useState } from 'react' +import { FaCode, FaGripVertical, FaMagic, FaPlus, FaTrash } from 'react-icons/fa' +import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' +import { + GROUP_MODES, + GROUP_MODE_LABEL, + isAggregateMode, + orColumnCount, + quoteLiteral, + type CriteriaRow, + type GroupMode, + type ViewSource, +} from './viewModel' + +interface CriteriaGridProps { + rows: CriteriaRow[] + sources: ViewSource[] + columnsOf: (sourceId: string) => DatabaseColumnDto[] + grouped: boolean + onUpdateRow: (id: string, patch: Partial) => void + onUpdateFilter: (id: string, index: number, value: string) => void + onRemoveRow: (id: string) => void + onMoveRow: (id: string, delta: number) => void + onAddRow: () => void + onAddExpression: () => void + labels: { + column: string + alias: string + table: string + output: string + groupBy: string + sortType: string + sortOrder: string + filter: string + or: string + addRow: string + addExpression: string + expressionPlaceholder: string + empty: string + builder: string + builderValue: string + builderLiteral: string + builderExpression: string + apply: string + } +} + +const OPERATORS = [ + '=', + '<>', + '>', + '>=', + '<', + '<=', + 'LIKE', + 'NOT LIKE', + 'IN', + 'NOT IN', + 'IS NULL', + 'IS NOT NULL', + 'BETWEEN', +] + +export function CriteriaGrid({ + rows, + sources, + columnsOf, + grouped, + onUpdateRow, + onUpdateFilter, + onRemoveRow, + onMoveRow, + onAddRow, + onAddExpression, + labels, +}: CriteriaGridProps) { + const [builder, setBuilder] = useState<{ rowId: string; index: number } | null>(null) + + const orCount = orColumnCount(rows) + + const cellCls = + 'w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-gray-300 focus:border-blue-500 focus:bg-white dark:text-gray-100 dark:hover:border-gray-600 dark:focus:bg-gray-700' + + return ( +
+
+ + + {rows.length} +
+ +
+ + + + + + + + {grouped && ( + + )} + + + {Array.from({ length: orCount }).map((_, i) => ( + + ))} + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row) => { + const isAgg = grouped && isAggregateMode(row.groupMode) + const isExpression = row.expression !== undefined + return ( + + + + + + + + + + + + {grouped && ( + + )} + + + + + + {Array.from({ length: orCount }).map((_, i) => ( + + ))} + + + + ) + })} + +
+ + {labels.column} + + {labels.alias} + + {labels.table} + + {labels.output} + + {labels.groupBy} + + {labels.sortType} + + {labels.sortOrder} + + {i === 0 ? labels.filter : labels.or} + +
+ {labels.empty} +
+
+ + + +
+
+ {isExpression ? ( + onUpdateRow(row.id, { expression: e.target.value })} + /> + ) : ( + + )} + + onUpdateRow(row.id, { alias: e.target.value })} + /> + + {isExpression ? ( + SQL + ) : ( + + )} + + onUpdateRow(row.id, { output: e.target.checked })} + /> + + + + + + + onUpdateRow(row.id, { sortOrder: Number(e.target.value) || 0 }) + } + /> + +
+ 100 · = 'A' · IS NULL" : ''} + value={row.filters[i] ?? ''} + onChange={(e) => onUpdateFilter(row.id, i, e.target.value)} + /> + +
+ {builder?.rowId === row.id && builder.index === i && ( + { + onUpdateFilter(row.id, i, text) + setBuilder(null) + }} + onClose={() => setBuilder(null)} + /> + )} + {isAgg && (row.filters[i] ?? '').trim() && ( + + HAVING + + )} +
+ +
+
+
+ ) +} + +// ─── Filtre yardimcisi ──────────────────────────────────────────────────────── + +function FilterBuilder({ + labels, + onApply, + onClose, +}: { + labels: CriteriaGridProps['labels'] + onApply: (text: string) => void + onClose: () => void +}) { + const [operator, setOperator] = useState('=') + const [value, setValue] = useState('') + const [value2, setValue2] = useState('') + const [mode, setMode] = useState<'literal' | 'expression'>('literal') + + const needsValue = operator !== 'IS NULL' && operator !== 'IS NOT NULL' + + const build = () => { + if (!needsValue) return operator + const render = (v: string) => (mode === 'literal' ? quoteLiteral(v.trim()) : v.trim()) + if (operator === 'BETWEEN') return `BETWEEN ${render(value)} AND ${render(value2)}` + if (operator === 'IN' || operator === 'NOT IN') { + if (mode === 'expression') return `${operator} (${value.trim()})` + const items = value + .split(',') + .map((v) => v.trim()) + .filter(Boolean) + .map(quoteLiteral) + return `${operator} (${items.join(', ')})` + } + return `${operator} ${render(value)}` + } + + return ( +
e.stopPropagation()} + > + + {needsValue && ( + <> + setValue(e.target.value)} + /> + {operator === 'BETWEEN' && ( + setValue2(e.target.value)} + /> + )} + + + )} +
+ + +
+
+ ) +} + +export default CriteriaGrid diff --git a/ui/src/views/developerKit/sqlViewDesigner/DiagramPane.tsx b/ui/src/views/developerKit/sqlViewDesigner/DiagramPane.tsx new file mode 100644 index 00000000..865d0fbc --- /dev/null +++ b/ui/src/views/developerKit/sqlViewDesigner/DiagramPane.tsx @@ -0,0 +1,726 @@ +/** + * View Designer — Diagram paneli (SSMS Query Designer'in ust bolumu). + * + * Tablolar surukle-birak ile konumlandirilir (WorkflowCanvas ile ayni + * `@dnd-kit/core` deseni), kolonlarin yanindaki tutamaca tiklanip hedef kolona + * tiklanarak JOIN kurulur, join cizgisine tiklanarak tipi degistirilir. + * + * Kutular sabit yukseklikte tutulur; kolon sayisi fazlaysa kutu icinde + * kaydirilir ve alt kenardan yukseklik ayarlanabilir. Kaydirilan kolonun capa + * noktasi kutu kenarina kenetlenir, boylece cizgi her zaman kutuya baglanir. + */ +import { useCallback, useMemo, useRef, useState } from 'react' +import { useDraggable } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' +import classNames from 'classnames' +import { + FaChevronDown, + FaChevronRight, + FaCompressAlt, + FaExpandAlt, + FaEye, + FaLayerGroup, + FaTable, + FaTimes, +} from 'react-icons/fa' +import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' +import { + BOX_FOOTER_HEIGHT, + BOX_HEADER_HEIGHT, + BOX_MIN_ROWS, + BOX_PADDING, + BOX_ROW_HEIGHT, + BOX_WIDTH, + JOIN_KINDS, + JOIN_OPERATORS, + boxHeight, + isDerivedSource, + type JoinKind, + type JoinOperator, + type ViewSource, +} from './viewModel' + +export interface PendingLink { + sourceId: string + columnName: string +} + +/** Diagramda cizilecek tek bir join kosulu. */ +interface JoinLine { + key: string + /** Join'i tasiyan (sag taraftaki) kaynak. */ + sourceId: string + conditionId: string + leftSourceId: string + leftColumn: string + rightColumn: string + operator: JoinOperator + joinKind: JoinKind +} + +interface DiagramPaneProps { + sources: ViewSource[] + columnsOf: (sourceId: string) => DatabaseColumnDto[] + loadingSourceIds: Set + /** Bir kolon ciktiya secili mi. */ + isColumnSelected: (sourceId: string, columnName: string) => boolean + zoom: number + dragPreview: { id: string; delta: { x: number; y: number } } | null + selectedJoinKey: string | null + pendingLink: PendingLink | null + /** Ifade/alt sorgu metinleri alias'lara metin olarak baglidir; kilitli tutulur. */ + lockAliases: boolean + onToggleColumn: (sourceId: string, columnName: string) => void + onToggleAllColumns: (sourceId: string) => void + onRemoveSource: (sourceId: string) => void + onUpdateSource: (sourceId: string, patch: Partial) => void + onBeginLink: (link: PendingLink | null) => void + onCompleteLink: (target: PendingLink) => void + onSelectJoin: (key: string | null) => void + onUpdateJoin: (sourceId: string, conditionId: string, patch: { operator?: JoinOperator }) => void + onRemoveJoin: (sourceId: string, conditionId: string) => void + emptyHint: string + pendingHint: string + labels: { + alias: string + selectAll: string + onlySelected: string + allColumns: string + joinType: string + removeJoin: string + removeTable: string + resize: string + aliasLocked: string + } +} + +/** Kutunun gorunur kolon listesi. */ +const visibleColumns = ( + src: ViewSource, + all: DatabaseColumnDto[], + isSelected: (sourceId: string, columnName: string) => boolean, +) => (src.showSelectedOnly ? all.filter((c) => isSelected(src.id, c.columnName)) : all) + +/** Kolon listesinin kaydirilabilir alaninin yuksekligi. */ +const listHeight = (src: ViewSource, columnCount: number) => + Math.max(1, Math.min(src.visibleRows, columnCount || 1)) * BOX_ROW_HEIGHT + +const JOIN_COLOR: Record = { + INNER: '#2563eb', + LEFT: '#16a34a', + RIGHT: '#b45309', + FULL: '#7c3aed', + CROSS: '#64748b', +} + +export function DiagramPane({ + sources, + columnsOf, + loadingSourceIds, + isColumnSelected, + zoom, + dragPreview, + selectedJoinKey, + pendingLink, + lockAliases, + onToggleColumn, + onToggleAllColumns, + onRemoveSource, + onUpdateSource, + onBeginLink, + onCompleteLink, + onSelectJoin, + onUpdateJoin, + onRemoveJoin, + emptyHint, + pendingHint, + labels, +}: DiagramPaneProps) { + const [joinMenu, setJoinMenu] = useState<{ key: string; x: number; y: number } | null>(null) + const [hoveredJoin, setHoveredJoin] = useState(null) + /** Kutu ici kaydirma ofsetleri; capa noktalari bunlara gore kayar. */ + const [scrollTops, setScrollTops] = useState>({}) + + const handleScroll = useCallback( + (sourceId: string, top: number) => + setScrollTops((prev) => (prev[sourceId] === top ? prev : { ...prev, [sourceId]: top })), + [], + ) + + /** Surukleme onizlemesi uygulanmis konumlar. */ + const positioned = useMemo( + () => + sources.map((s) => + dragPreview?.id === s.id + ? { ...s, x: s.x + dragPreview.delta.x, y: s.y + dragPreview.delta.y } + : s, + ), + [sources, dragPreview], + ) + + const layout = useMemo(() => { + const map = new Map< + string, + { + src: ViewSource + columns: DatabaseColumnDto[] + height: number + listH: number + indexOf: Map + } + >() + positioned.forEach((s) => { + const cols = visibleColumns(s, columnsOf(s.id), isColumnSelected) + map.set(s.id, { + src: s, + columns: cols, + height: boxHeight(s, cols.length), + listH: listHeight(s, cols.length), + indexOf: new Map(cols.map((c, i) => [c.columnName.toLowerCase(), i])), + }) + }) + return map + }, [positioned, columnsOf, isColumnSelected]) + + const lines = useMemo(() => { + const out: JoinLine[] = [] + sources.slice(1).forEach((s) => { + if (s.joinKind === 'CROSS') return + s.conditions.forEach((c) => { + if (!c.leftSourceId || !c.leftColumn || !c.rightColumn) return + out.push({ + key: `${s.id}:${c.id}`, + sourceId: s.id, + conditionId: c.id, + leftSourceId: c.leftSourceId, + leftColumn: c.leftColumn, + rightColumn: c.rightColumn, + operator: c.operator, + joinKind: s.joinKind, + }) + }) + }) + return out + }, [sources]) + + const canvasWidth = Math.max(900, ...positioned.map((s) => s.x + BOX_WIDTH + 80)) + const canvasHeight = Math.max( + 340, + ...positioned.map((s) => s.y + (layout.get(s.id)?.height ?? 120) + 60), + ) + + /** + * Kolonun capa noktasi. Kolon kaydirma yuzunden gorunmuyorsa liste alaninin + * ust/alt kenarina kenetlenir; kutu daraltilmissa baslik hizasi kullanilir. + */ + const anchorOf = (sourceId: string, columnName: string) => { + const entry = layout.get(sourceId) + if (!entry) return null + const { src, listH, indexOf } = entry + if (src.collapsed) { + return { boxX: src.x, y: src.y + BOX_HEADER_HEIGHT / 2 } + } + const idx = indexOf.get(columnName.toLowerCase()) + const listTop = src.y + BOX_HEADER_HEIGHT + BOX_PADDING + if (idx === undefined) { + return { boxX: src.x, y: listTop + listH / 2 } + } + const scroll = scrollTops[src.id] ?? 0 + const raw = listTop + idx * BOX_ROW_HEIGHT + BOX_ROW_HEIGHT / 2 - scroll + const min = listTop + 3 + const max = listTop + listH - 3 + return { boxX: src.x, y: Math.min(max, Math.max(min, raw)) } + } + + const renderLine = (line: JoinLine) => { + const left = anchorOf(line.leftSourceId, line.leftColumn) + const right = anchorOf(line.sourceId, line.rightColumn) + if (!left || !right) return null + + // Kutular arasindaki yone gore capa kenarini sec. + const leftIsWest = left.boxX <= right.boxX + const x1 = leftIsWest ? left.boxX + BOX_WIDTH : left.boxX + const x2 = leftIsWest ? right.boxX : right.boxX + BOX_WIDTH + const y1 = left.y + const y2 = right.y + const dir = leftIsWest ? 1 : -1 + const stub = 18 + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + const r = Math.min(8, Math.abs(y2 - y1) / 2, Math.abs(midX - (x1 + dir * stub)) || 8) + + // Ortogonal yol: kisa cikis -> dikey -> kisa giris (SSMS'teki gibi). + const path = + Math.abs(y2 - y1) < 2 + ? `M ${x1} ${y1} L ${x2} ${y2}` + : [ + `M ${x1} ${y1}`, + `L ${midX - dir * r} ${y1}`, + `Q ${midX} ${y1} ${midX} ${y1 + Math.sign(y2 - y1) * r}`, + `L ${midX} ${y2 - Math.sign(y2 - y1) * r}`, + `Q ${midX} ${y2} ${midX + dir * r} ${y2}`, + `L ${x2} ${y2}`, + ].join(' ') + + const color = JOIN_COLOR[line.joinKind] + const active = selectedJoinKey === line.key || hoveredJoin === line.key + const open = (e: React.MouseEvent) => { + e.stopPropagation() + onSelectJoin(line.key) + setJoinMenu({ key: line.key, x: midX, y: midY }) + } + + return ( + + + {/* Uc noktalari kutuya kenetler */} + + + {/* Tiklama alanini genisleten seffaf yol */} + setHoveredJoin(line.key)} + onMouseLeave={() => setHoveredJoin(null)} + /> + {/* SSMS'teki elmas isaret; detay yalnizca hover/secimde acilir */} + setHoveredJoin(line.key)} + onMouseLeave={() => setHoveredJoin(null)} + > + {`${line.joinKind} JOIN · ${line.leftColumn} ${line.operator} ${line.rightColumn}`} + + + {line.operator} + + + {active && ( + + + + {line.joinKind} + + + )} + + ) + } + + const activeJoin = joinMenu ? lines.find((l) => l.key === joinMenu.key) : undefined + const activeJoinSource = activeJoin + ? sources.find((s) => s.id === activeJoin.sourceId) + : undefined + + return ( +
{ + setJoinMenu(null) + onSelectJoin(null) + onBeginLink(null) + }} + > + {pendingLink && ( +
+ {pendingHint} +
+ )} + {sources.length === 0 && ( +
+ {emptyHint} +
+ )} + +
+
+ + + {positioned.map((src) => { + const entry = layout.get(src.id) + return ( + + ) + })} + + {joinMenu && activeJoin && activeJoinSource && ( +
e.stopPropagation()} + > +
+ {labels.joinType} +
+ + + +
+ )} +
+
+
+ ) +} + +// ─── Tablo kutusu ───────────────────────────────────────────────────────────── + +interface TableBoxProps { + src: ViewSource + columns: DatabaseColumnDto[] + listH: number + loading: boolean + isColumnSelected: (sourceId: string, columnName: string) => boolean + pendingLink: PendingLink | null + lockAliases: boolean + labels: DiagramPaneProps['labels'] + zoom: number + onScroll: (sourceId: string, top: number) => void + onToggleColumn: (sourceId: string, columnName: string) => void + onToggleAllColumns: (sourceId: string) => void + onRemoveSource: (sourceId: string) => void + onUpdateSource: (sourceId: string, patch: Partial) => void + onBeginLink: (link: PendingLink | null) => void + onCompleteLink: (target: PendingLink) => void +} + +function TableBox({ + src, + columns, + listH, + loading, + isColumnSelected, + pendingLink, + lockAliases, + labels, + zoom, + onScroll, + onToggleColumn, + onToggleAllColumns, + onRemoveSource, + onUpdateSource, + onBeginLink, + onCompleteLink, +}: TableBoxProps) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: src.id, + disabled: Boolean(pendingLink), + }) + const resizeState = useRef<{ startY: number; startRows: number } | null>(null) + + const isLinkSource = pendingLink?.sourceId === src.id + const derived = isDerivedSource(src) + const maxRows = Math.max(1, columns.length) + + const beginResize = (e: React.PointerEvent) => { + e.stopPropagation() + e.preventDefault() + resizeState.current = { startY: e.clientY, startRows: Math.min(src.visibleRows, maxRows) } + e.currentTarget.setPointerCapture(e.pointerId) + } + + const onResize = (e: React.PointerEvent) => { + const state = resizeState.current + if (!state) return + const deltaRows = Math.round((e.clientY - state.startY) / zoom / BOX_ROW_HEIGHT) + const next = Math.min(maxRows, Math.max(BOX_MIN_ROWS, state.startRows + deltaRows)) + if (next !== src.visibleRows) onUpdateSource(src.id, { visibleRows: next }) + } + + const endResize = (e: React.PointerEvent) => { + resizeState.current = null + e.currentTarget.releasePointerCapture(e.pointerId) + } + + return ( +
e.stopPropagation()} + > + {/* Baslik — surukleme tutamaci */} +
+ {derived ? ( + + ) : ( + + )} + + {src.objectName} + + {derived && ( + + {src.applyKind ? `${src.applyKind} APPLY` : 'SUB'} + + )} + e.stopPropagation()} + onChange={(e) => onUpdateSource(src.id, { alias: e.target.value })} + /> + + + +
+ + {!src.collapsed && ( + <> +
onScroll(src.id, e.currentTarget.scrollTop)} + > + {loading && ( +
+ … +
+ )} + {!loading && columns.length === 0 && ( +
+ — +
+ )} + {columns.map((col) => { + const selected = isColumnSelected(src.id, col.columnName) + const isPendingCell = + pendingLink?.sourceId === src.id && pendingLink.columnName === col.columnName + const linkable = pendingLink && pendingLink.sourceId !== src.id + return ( +
{ + if (linkable) onCompleteLink({ sourceId: src.id, columnName: col.columnName }) + }} + > + e.stopPropagation()} + onChange={() => onToggleColumn(src.id, col.columnName)} + /> + + {col.columnName} + + {/* Join tutamaci — tiklayip hedef kolona tiklanir */} +
+ ) + })} +
+ +
+ + + {Math.min(src.visibleRows, maxRows)}/{columns.length} + + {/* Yukseklik tutamaci */} + + + + + +
+ + )} +
+ ) +} + +export default DiagramPane diff --git a/ui/src/views/developerKit/sqlViewDesigner/viewModel.ts b/ui/src/views/developerKit/sqlViewDesigner/viewModel.ts new file mode 100644 index 00000000..cf81e2b0 --- /dev/null +++ b/ui/src/views/developerKit/sqlViewDesigner/viewModel.ts @@ -0,0 +1,1181 @@ +/** + * SQL View Designer — gorsel tasarimcinin veri modeli, T-SQL uretimi ve + * "best effort" geri-ayristirma (parse) mantigi. + * + * Model SSMS Query Designer'i taklit eder: + * - Diagram paneli: `ViewSource` kutulari (x/y konumlu) ve aralarindaki JOIN'ler. + * Kaynak bir tablo/view olabilecegi gibi `CROSS/OUTER APPLY` ya da turetilmis + * bir alt sorgu da olabilir; alt sorgunun govdesi oldugu gibi saklanir. + * - Criteria grid: `CriteriaRow` satirlari (Column / Alias / Output / Group By / + * Sort / Filter / Or...). Bir satir ya `sourceId + columnName` referansidir ya + * da serbest bir SQL ifadesidir (`expression`). Filtre hucreleri SSMS'teki + * gibi serbest yuklemdir ("> 100", "LIKE '%abc%'", "IS NULL"). + * + * Tasarimci tek yonlu calisir: model -> T-SQL. Mevcut bir view acilirken + * `parseViewSql` denenir; uretilen kanonik sekle uymayan bir tanim gelirse + * `null` doner ve dialog ham SQL moduna duser (kullanici Query Editor'dan + * devam eder). + */ + +// ─── Model ──────────────────────────────────────────────────────────────────── + +export type JoinKind = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL' | 'CROSS' + +export type JoinOperator = '=' | '<>' | '>' | '>=' | '<' | '<=' + +/** APPLY operatoru; dolu ise kaynak bir alt sorgudur ve ON kosulu almaz. */ +export type ApplyKind = 'CROSS' | 'OUTER' + +/** + * SSMS'teki "Group By" sutununun degerleri. `Where` = satir ciktiya girmez, + * yalnizca filtre tasir (gruplama acikken kosulu WHERE'e yazar). + */ +export type GroupMode = + 'GroupBy' | 'Where' | 'SUM' | 'COUNT' | 'COUNT_DISTINCT' | 'AVG' | 'MIN' | 'MAX' + +const AGGREGATE_MODES: GroupMode[] = ['SUM', 'COUNT', 'COUNT_DISTINCT', 'AVG', 'MIN', 'MAX'] + +export const GROUP_MODES: GroupMode[] = ['GroupBy', 'Where', ...AGGREGATE_MODES] + +export const GROUP_MODE_LABEL: Record = { + GroupBy: 'Group By', + Where: 'Where', + SUM: 'Sum', + COUNT: 'Count', + COUNT_DISTINCT: 'Count Distinct', + AVG: 'Avg', + MIN: 'Min', + MAX: 'Max', +} + +export const isAggregateMode = (mode: GroupMode) => AGGREGATE_MODES.includes(mode) + +export interface JoinCondition { + id: string + /** Sol taraftaki kaynak (daha once eklenmis bir source). */ + leftSourceId: string + leftColumn: string + operator: JoinOperator + /** Sag taraf her zaman join edilen kaynagin kendisidir. */ + rightColumn: string +} + +export interface ViewSource { + id: string + schemaName: string + objectName: string + alias: string + /** Ilk kaynak icin ve APPLY kaynaklarinda anlamsizdir. */ + joinKind: JoinKind + conditions: JoinCondition[] + /** Diagram panelindeki konum. */ + x: number + y: number + /** Kutu daraltilmis mi (yalnizca baslik gorunur). */ + collapsed: boolean + /** Kutuda yalnizca ciktiya secili kolonlari goster. */ + showSelectedOnly: boolean + /** Kutuda ayni anda gorunecek kolon sayisi; fazlasi kutu icinde kaydirilir. */ + visibleRows: number + /** Dolu ise kaynak bir alt sorgudur; govde oldugu gibi korunur. */ + derivedSql?: string + /** Alt sorgu bir APPLY ise operatoru. */ + applyKind?: ApplyKind + /** Alt sorgunun cikti kolonlari (govdeden okunur, katalogdan degil). */ + derivedColumns?: string[] +} + +/** Kaynak DB'den kolon cekilebilir bir tablo/view mi. */ +export const isDerivedSource = (src: ViewSource) => typeof src.derivedSql === 'string' + +export interface CriteriaRow { + id: string + /** Ifade satirlarinda bostur. */ + sourceId: string + /** Ifade satirlarinda bostur. */ + columnName: string + /** Dolu ise satir bir kolon degil, serbest bir SQL ifadesidir. */ + expression?: string + /** Bos ise kolon adi kullanilir; ifade satirlarinda zorunludur. */ + alias: string + /** SELECT listesinde yer alsin mi. */ + output: boolean + groupMode: GroupMode + sortType: '' | 'ASC' | 'DESC' + /** 0 = siralamada yok. */ + sortOrder: number + /** + * SSMS'teki "Filter" + "Or..." sutunlari. Index = OR grubu. + * Hucre serbest metin yuklemidir: "> 100", "= 'A'", "IN (1,2)", "IS NULL". + */ + filters: string[] +} + +export interface ViewSettings { + schemaName: string + viewName: string + distinct: boolean + /** SSMS'teki Sigma dugmesi: kapaliyken Group By sutunu ve GROUP BY uretimi yoktur. */ + groupBy: boolean + topEnabled: boolean + topCount: string + topPercent: boolean +} + +export interface ViewModel { + sources: ViewSource[] + rows: CriteriaRow[] + settings: ViewSettings +} + +export const JOIN_OPERATORS: JoinOperator[] = ['=', '<>', '>', '>=', '<', '<='] + +export const JOIN_KINDS: JoinKind[] = ['INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS'] + +/** Diagram kutusu olculeri; kolon capa noktalari bu sabitlerden hesaplanir. */ +export const BOX_WIDTH = 220 +export const BOX_HEADER_HEIGHT = 30 +export const BOX_ROW_HEIGHT = 22 +export const BOX_PADDING = 4 +export const BOX_FOOTER_HEIGHT = 18 +export const BOX_DEFAULT_ROWS = 8 +export const BOX_MIN_ROWS = 2 + +/** Kutunun toplam yuksekligi (baslik + gorunur kolonlar + alt serit). */ +export const boxHeight = (src: ViewSource, columnCount: number) => { + if (src.collapsed) return BOX_HEADER_HEIGHT + const rows = Math.max(1, Math.min(src.visibleRows, columnCount || 1)) + return BOX_HEADER_HEIGHT + BOX_PADDING * 2 + rows * BOX_ROW_HEIGHT + BOX_FOOTER_HEIGHT +} + +export const newId = () => crypto.randomUUID() + +export const createEmptySettings = (isPostgreSql: boolean): ViewSettings => ({ + schemaName: isPostgreSql ? 'public' : 'dbo', + viewName: '', + distinct: false, + groupBy: false, + topEnabled: false, + topCount: '100', + topPercent: false, +}) + +export const createRow = (sourceId: string, columnName: string): CriteriaRow => ({ + id: newId(), + sourceId, + columnName, + alias: '', + output: true, + groupMode: 'GroupBy', + sortType: '', + sortOrder: 0, + filters: [''], +}) + +export const createExpressionRow = (expression = '', alias = ''): CriteriaRow => ({ + id: newId(), + sourceId: '', + columnName: '', + expression, + alias, + output: true, + groupMode: 'GroupBy', + sortType: '', + sortOrder: 0, + filters: [''], +}) + +/** "Sas_D_Order" -> "o", cakisma halinde "o2", "o3"... */ +export const suggestAlias = (objectName: string, taken: string[]) => { + const letters = objectName.replace(/[^A-Za-z]/g, '') + const base = (letters.charAt(0) || 't').toLowerCase() + if (!taken.includes(base)) return base + let i = 2 + while (taken.includes(`${base}${i}`)) i++ + return `${base}${i}` +} + +/** Diagramda bir sonraki kutunun serbest konumu. */ +export const nextSourcePosition = (index: number) => ({ + x: 24 + (index % 3) * (BOX_WIDTH + 60), + y: 24 + Math.floor(index / 3) * 260, +}) + +/** Satirin filtresi HAVING'e mi WHERE'e mi gider. */ +const filterTarget = (row: CriteriaRow, grouped: boolean): 'where' | 'having' => + grouped && isAggregateMode(row.groupMode) ? 'having' : 'where' + +/** Grid'de kac tane filtre sutunu gosterilecek (son sutun her zaman bos kalir). */ +export const orColumnCount = (rows: CriteriaRow[]) => { + const used = Math.max( + 0, + ...rows.map((r) => r.filters.reduce((acc, f, i) => (f.trim() ? i + 1 : acc), 0)), + ) + return Math.max(2, used + 1) +} + +// ─── Identifier / literal quoting ───────────────────────────────────────────── + +const quoteIdent = (name: string, isPostgreSql: boolean) => + isPostgreSql ? `"${name.replace(/"/g, '""')}"` : `[${name.replace(/]/g, ']]')}]` + +export const quoteLiteral = (value: string) => `'${value.replace(/'/g, "''")}'` + +// ─── Validation ─────────────────────────────────────────────────────────────── + +export type ValidationArea = 'diagram' | 'criteria' | 'settings' + +export interface ValidationIssue { + area: ValidationArea + message: string +} + +export interface ValidationTexts { + noSource: string + aliasRequired: string + aliasDuplicate: string + joinConditionRequired: string + noOutputColumn: string + columnAliasDuplicate: string + expressionAliasRequired: string + viewNameRequired: string + viewNameInvalid: string +} + +const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_@$#]*$/ + +export const validateViewModel = (model: ViewModel, t: ValidationTexts): ValidationIssue[] => { + const issues: ValidationIssue[] = [] + const { sources, rows, settings } = model + + if (sources.length === 0) { + issues.push({ area: 'diagram', message: t.noSource }) + } + + const aliases = sources.map((s) => s.alias.trim().toLowerCase()) + sources.forEach((s) => { + if (!s.alias.trim()) { + issues.push({ area: 'diagram', message: `${s.objectName}: ${t.aliasRequired}` }) + } + }) + aliases.forEach((a, i) => { + if (a && aliases.indexOf(a) !== i) { + issues.push({ area: 'diagram', message: `${a}: ${t.aliasDuplicate}` }) + } + }) + + sources.slice(1).forEach((s) => { + // APPLY ve CROSS JOIN kaynaklari ON kosulu almaz. + if (s.applyKind || s.joinKind === 'CROSS') return + const complete = s.conditions.filter((c) => c.leftSourceId && c.leftColumn && c.rightColumn) + if (complete.length === 0) { + issues.push({ + area: 'diagram', + message: `${s.alias || s.objectName}: ${t.joinConditionRequired}`, + }) + } + }) + + const outputRows = rows.filter((r) => r.output) + if (outputRows.length === 0) { + issues.push({ area: 'criteria', message: t.noOutputColumn }) + } + outputRows.forEach((r) => { + if (r.expression !== undefined && !r.alias.trim()) { + issues.push({ area: 'criteria', message: t.expressionAliasRequired }) + } + }) + const outAliases = outputRows.map((r) => (r.alias.trim() || r.columnName).toLowerCase()) + outAliases.forEach((a, i) => { + if (a && outAliases.indexOf(a) !== i) { + issues.push({ area: 'criteria', message: `${a}: ${t.columnAliasDuplicate}` }) + } + }) + + if (!settings.viewName.trim()) { + issues.push({ area: 'settings', message: t.viewNameRequired }) + } else if (!IDENT_RE.test(settings.viewName.trim())) { + issues.push({ area: 'settings', message: t.viewNameInvalid }) + } + + return issues +} + +// ─── SQL generation ─────────────────────────────────────────────────────────── + +const aggExpr = (mode: GroupMode, ref: string) => { + if (!isAggregateMode(mode)) return ref + if (mode === 'COUNT_DISTINCT') return `COUNT(DISTINCT ${ref})` + return `${mode}(${ref})` +} + +export interface GenerateOptions { + isPostgreSql: boolean +} + +export const generateViewSql = (model: ViewModel, opts: GenerateOptions): string => { + const { isPostgreSql } = opts + const q = (v: string) => quoteIdent(v, isPostgreSql) + const { sources, rows, settings } = model + + const outputRows = rows.filter((r) => r.output) + if (sources.length === 0 || outputRows.length === 0) return '' + + const sourceById = new Map(sources.map((s) => [s.id, s])) + const colRef = (row: CriteriaRow) => { + if (row.expression !== undefined) return row.expression.trim() + const src = sourceById.get(row.sourceId) + if (!src) return q(row.columnName) + return `${q(src.alias)}.${q(row.columnName)}` + } + const joinRef = (sourceId: string, columnName: string) => { + const src = sourceById.get(sourceId) + if (!src) return q(columnName) + return `${q(src.alias)}.${q(columnName)}` + } + + const grouped = settings.groupBy + const rowExpr = (row: CriteriaRow) => aggExpr(grouped ? row.groupMode : 'GroupBy', colRef(row)) + + // SELECT + const selectPrefix: string[] = [] + if (settings.distinct) selectPrefix.push('DISTINCT') + if (!isPostgreSql && settings.topEnabled && settings.topCount.trim()) { + selectPrefix.push(`TOP (${settings.topCount.trim()})${settings.topPercent ? ' PERCENT' : ''}`) + } + + const sortRows = rows + .filter((r) => r.sortType) + .sort((a, b) => (a.sortOrder || 999) - (b.sortOrder || 999)) + const needsImplicitTop = + !isPostgreSql && sortRows.length > 0 && !(settings.topEnabled && settings.topCount.trim()) + if (needsImplicitTop) selectPrefix.push('TOP (100) PERCENT') + + const selectItems = outputRows.map((r) => { + const alias = r.alias.trim() || r.columnName + return ` ${rowExpr(r)} AS ${q(alias)}` + }) + + const prefix = selectPrefix.join(' ') + const lines: string[] = [] + lines.push(`SELECT${prefix ? ' ' + prefix : ''}`) + lines.push(selectItems.join(',\n')) + + /** Kaynagin FROM tarafindaki hedef ifadesi. */ + const targetOf = (s: ViewSource, indent: string) => + isDerivedSource(s) + ? `(\n${s.derivedSql}\n${indent}) AS ${q(s.alias)}` + : `${q(s.schemaName)}.${q(s.objectName)} AS ${q(s.alias)}` + + // FROM + JOIN / APPLY + const first = sources[0] + lines.push(`FROM ${targetOf(first, '')}`) + sources.slice(1).forEach((s) => { + const target = targetOf(s, ' ') + if (s.applyKind) { + lines.push(` ${s.applyKind} APPLY ${target}`) + return + } + if (s.joinKind === 'CROSS') { + lines.push(` CROSS JOIN ${target}`) + return + } + const kind = s.joinKind === 'INNER' ? 'INNER JOIN' : `${s.joinKind} OUTER JOIN` + const on = s.conditions + .filter((c) => c.leftSourceId && c.leftColumn && c.rightColumn) + .map( + (c) => + `${joinRef(c.leftSourceId, c.leftColumn)} ${c.operator} ${joinRef(s.id, c.rightColumn)}`, + ) + .join('\n AND ') + lines.push(` ${kind} ${target}\n ON ${on}`) + }) + + /** + * SSMS semantigi: her filtre sutunu bir OR grubudur, sutun icindeki + * satirlar AND ile baglanir. + */ + const buildPredicate = (target: 'where' | 'having') => { + const relevant = rows.filter((r) => filterTarget(r, grouped) === target) + const orCount = Math.max(0, ...relevant.map((r) => r.filters.length)) + const groups: string[] = [] + for (let oi = 0; oi < orCount; oi++) { + const conj = relevant + .filter((r) => (r.filters[oi] ?? '').trim()) + .map((r) => `${rowExpr(r)} ${r.filters[oi].trim()}`) + if (conj.length > 0) groups.push(conj.join(' AND ')) + } + if (groups.length === 0) return null + if (groups.length === 1) return ` ${groups[0]}` + return groups.map((g, i) => (i === 0 ? ` (${g})` : ` OR (${g})`)).join('\n') + } + + const whereText = buildPredicate('where') + if (whereText) lines.push('WHERE\n' + whereText) + + if (grouped) { + const groupRows = rows.filter((r) => r.groupMode === 'GroupBy') + if (groupRows.length > 0) { + lines.push('GROUP BY\n' + groupRows.map((r) => ` ${colRef(r)}`).join(',\n')) + } + const havingText = buildPredicate('having') + if (havingText) lines.push('HAVING\n' + havingText) + } + + if (sortRows.length > 0) { + lines.push('ORDER BY\n' + sortRows.map((r) => ` ${rowExpr(r)} ${r.sortType}`).join(',\n')) + } + + const body = lines.join('\n') + const viewFullName = `${q(settings.schemaName || (isPostgreSql ? 'public' : 'dbo'))}.${q( + settings.viewName.trim() || 'NewView', + )}` + + if (isPostgreSql) { + const pgBody = + settings.topEnabled && settings.topCount.trim() + ? `${body}\nLIMIT ${settings.topCount.trim()}` + : body + return `CREATE OR REPLACE VIEW ${viewFullName}\nAS\n${pgBody};` + } + + return `CREATE OR ALTER VIEW ${viewFullName}\nAS\n${body}\nGO` +} + +// ─── Tarama yardimcilari ────────────────────────────────────────────────────── + +interface Token { + key: string + start: number + end: number +} + +/** + * Verilen anahtar kelimeleri parantez / string / bracket disindaki en ust + * seviyede arar. Anahtarlar uzun olandan kisa olana dogru siralanmalidir. + */ +const scanTopLevel = (text: string, keywords: string[]): Token[] => { + const upper = text.toUpperCase() + const found: Token[] = [] + let depth = 0 + let inStr = false + let inBracket = false + let inDq = false + + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (ch === "'") { + if (text[i + 1] === "'") i++ + else inStr = false + } + continue + } + if (inBracket) { + if (ch === ']') inBracket = false + continue + } + if (inDq) { + if (ch === '"') inDq = false + continue + } + if (ch === "'") { + inStr = true + continue + } + if (ch === '[') { + inBracket = true + continue + } + if (ch === '"') { + inDq = true + continue + } + if (ch === '(') { + depth++ + continue + } + if (ch === ')') { + depth-- + continue + } + if (depth !== 0) continue + if (i > 0 && /[A-Za-z0-9_]/.test(text[i - 1])) continue + + for (const kw of keywords) { + if (!upper.startsWith(kw, i)) continue + const after = text[i + kw.length] + if (after !== undefined && /[A-Za-z0-9_]/.test(after)) continue + found.push({ key: kw, start: i, end: i + kw.length }) + i += kw.length - 1 + break + } + } + return found +} + +/** En ust seviyedeki karsilastirma operatorunu bulur (parantez/string disi). */ +const findTopLevelComparison = (text: string): { start: number; end: number } | null => { + let depth = 0 + let inStr = false + let inBracket = false + let inDq = false + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (ch === "'") { + if (text[i + 1] === "'") i++ + else inStr = false + } + continue + } + if (inBracket) { + if (ch === ']') inBracket = false + continue + } + if (inDq) { + if (ch === '"') inDq = false + continue + } + if (ch === "'") inStr = true + else if (ch === '[') inBracket = true + else if (ch === '"') inDq = true + else if (ch === '(') depth++ + else if (ch === ')') depth-- + else if (depth === 0) { + const two = text.slice(i, i + 2) + if (two === '<>' || two === '!=' || two === '>=' || two === '<=') { + return { start: i, end: i + 2 } + } + if (ch === '=' || ch === '>' || ch === '<') return { start: i, end: i + 1 } + } + } + return null +} + +/** En ust seviyedeki virgullere gore boler. */ +const splitTopLevelCommas = (text: string): string[] => { + const parts: string[] = [] + let depth = 0 + let inStr = false + let inBracket = false + let inDq = false + let last = 0 + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (ch === "'") { + if (text[i + 1] === "'") i++ + else inStr = false + } + continue + } + if (inBracket) { + if (ch === ']') inBracket = false + continue + } + if (inDq) { + if (ch === '"') inDq = false + continue + } + if (ch === "'") inStr = true + else if (ch === '[') inBracket = true + else if (ch === '"') inDq = true + else if (ch === '(') depth++ + else if (ch === ')') depth-- + else if (ch === ',' && depth === 0) { + parts.push(text.slice(last, i)) + last = i + 1 + } + } + parts.push(text.slice(last)) + return parts.map((p) => p.trim()).filter((p) => p.length > 0) +} + +/** Verilen token listesine gore metni parcalara ayirir. */ +const splitByTokens = (text: string, tokens: Token[]): string[] => { + const parts: string[] = [] + let cursor = 0 + tokens.forEach((tok) => { + parts.push(text.slice(cursor, tok.start)) + cursor = tok.end + }) + parts.push(text.slice(cursor)) + return parts +} + +/** + * `from` konumundaki '(' ile eslesen ')' arasini dondurur. + * String / bracket icindeki parantezler sayilmaz. + */ +const readParenBlock = (text: string, from: number): { inner: string; end: number } | null => { + const open = text.indexOf('(', from) + if (open < 0) return null + let depth = 0 + let inStr = false + let inBracket = false + let inDq = false + for (let i = open; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (ch === "'") { + if (text[i + 1] === "'") i++ + else inStr = false + } + continue + } + if (inBracket) { + if (ch === ']') inBracket = false + continue + } + if (inDq) { + if (ch === '"') inDq = false + continue + } + if (ch === "'") inStr = true + else if (ch === '[') inBracket = true + else if (ch === '"') inDq = true + else if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth === 0) return { inner: text.slice(open + 1, i), end: i + 1 } + } + } + return null +} + +const IDENT_PART = String.raw`(?:\[[^\]]+\]|"[^"]+"|[A-Za-z_][A-Za-z0-9_@$#]*)` + +const unquoteIdent = (raw: string) => { + const v = raw.trim() + if (v.startsWith('[') && v.endsWith(']')) return v.slice(1, -1).replace(/]]/g, ']') + if (v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1).replace(/""/g, '"') + return v +} + +/** "a.b.c" -> ['a','b','c'] (tirnaklar cozulmus). */ +const splitQualified = (raw: string): string[] | null => { + const re = new RegExp( + String.raw`^\s*(${IDENT_PART})(?:\s*\.\s*(${IDENT_PART}))?(?:\s*\.\s*(${IDENT_PART}))?\s*$`, + ) + const m = re.exec(raw) + if (!m) return null + return [m[1], m[2], m[3]].filter(Boolean).map((p) => unquoteIdent(p as string)) +} + +/** Alt sorgunun cikti kolon adlarini govdesinden okur. */ +const derivedOutputNames = (innerSql: string): string[] => { + const selectTok = scanTopLevel(innerSql, ['SELECT'])[0] + if (!selectTok) return [] + const stop = scanTopLevel(innerSql, ['FROM', 'WHERE', 'GROUP BY', 'ORDER BY']).find( + (t) => t.start > selectTok.end, + ) + let listText = innerSql.slice(selectTok.end, stop ? stop.start : innerSql.length) + listText = listText.replace(/^\s*DISTINCT\b/i, '') + listText = listText.replace(/^\s*TOP\s*(?:\(\s*\d+\s*\)|\d+)(?:\s+PERCENT)?/i, '') + + const aliasRe = new RegExp(String.raw`\s+AS\s+(${IDENT_PART})\s*$`, 'i') + const assignRe = new RegExp(String.raw`^\s*(${IDENT_PART})\s*=`) + + return splitTopLevelCommas(listText).map((item, i) => { + const am = aliasRe.exec(item) + if (am) return unquoteIdent(am[1]) + const asg = assignRe.exec(item) + if (asg) return unquoteIdent(asg[1]) + const parts = splitQualified(item) + if (parts) return parts[parts.length - 1] + return `Column${i + 1}` + }) +} + +/** SUM(x) / COUNT(DISTINCT x) sarmalini acar. */ +const peelAggregate = (raw: string): { mode: GroupMode; inner: string } => { + const m = /^\s*(SUM|COUNT|AVG|MIN|MAX)\s*\(\s*([\s\S]*?)\s*\)\s*$/i.exec(raw) + if (!m) return { mode: 'GroupBy', inner: raw } + const fn = m[1].toUpperCase() + let inner = m[2] + let mode = fn as GroupMode + if (/^\s*DISTINCT\s+/i.test(inner)) { + if (fn !== 'COUNT') return { mode: 'GroupBy', inner: raw } + mode = 'COUNT_DISTINCT' + inner = inner.replace(/^\s*DISTINCT\s+/i, '') + } + return { mode, inner } +} + +// ─── Best-effort parser ─────────────────────────────────────────────────────── + +/** + * Tasarimcinin urettigi kanonik `CREATE [OR ALTER] VIEW ... AS SELECT ...` + * yapisini ve SSMS'in urettigi yaygin varyantlari geri okur. + * Desteklenmeyen her durumda `null` doner. + */ +export const parseViewSql = (sqlText: string): ViewModel | null => { + if (!sqlText?.trim()) return null + + // Yorum satirlarini ve GO ayiracini temizle. + let sql = sqlText + .replace(/\r\n/g, '\n') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n]*/g, ' ') + .replace(/^\s*GO\s*$/gim, ' ') + .replace(/^\s*USE\s+[^\n]*$/gim, ' ') + .trim() + sql = sql.replace(/;\s*$/, '') + + const headerRe = new RegExp( + String.raw`^\s*CREATE\s+(?:OR\s+ALTER\s+|OR\s+REPLACE\s+)?VIEW\s+((?:${IDENT_PART}\s*\.\s*)?${IDENT_PART})\s+AS\s+`, + 'i', + ) + const header = headerRe.exec(sql) + if (!header) return null + + const nameParts = splitQualified(header[1]) + if (!nameParts) return null + const viewSchema = nameParts.length > 1 ? nameParts[0] : 'dbo' + const viewName = nameParts[nameParts.length - 1] + + const body = sql.slice(header[0].length).trim() + + const clauseTokens = scanTopLevel(body, [ + 'SELECT', + 'FROM', + 'WHERE', + 'GROUP BY', + 'HAVING', + 'ORDER BY', + 'UNION', + 'EXCEPT', + 'INTERSECT', + 'WITH', + ]) + + if (clauseTokens.some((t) => ['UNION', 'EXCEPT', 'INTERSECT', 'WITH'].includes(t.key))) { + return null + } + if (clauseTokens.length < 2) return null + if (clauseTokens[0].key !== 'SELECT' || clauseTokens[1].key !== 'FROM') return null + + const ORDER = ['SELECT', 'FROM', 'WHERE', 'GROUP BY', 'HAVING', 'ORDER BY'] + let lastIdx = -1 + const clauses: Record = {} + for (let i = 0; i < clauseTokens.length; i++) { + const tok = clauseTokens[i] + const idx = ORDER.indexOf(tok.key) + if (idx <= lastIdx) return null // tekrar eden / sirasiz clause + lastIdx = idx + const next = clauseTokens[i + 1] + clauses[tok.key] = body.slice(tok.end, next ? next.start : body.length).trim() + } + + // ── SELECT basligi ── + let selectText = clauses['SELECT'] + let distinct = false + let topEnabled = false + let topCount = '100' + let topPercent = false + + const distinctM = /^\s*DISTINCT\b/i.exec(selectText) + if (distinctM) { + distinct = true + selectText = selectText.slice(distinctM[0].length) + } + if (/^\s*ALL\b/i.test(selectText)) selectText = selectText.replace(/^\s*ALL\b/i, '') + + const topM = /^\s*TOP\s*(?:\(\s*(\d+)\s*\)|(\d+))(?:\s+(PERCENT))?/i.exec(selectText) + if (topM) { + topEnabled = true + topCount = topM[1] ?? topM[2] + topPercent = !!topM[3] + selectText = selectText.slice(topM[0].length) + } + + const selectParts = splitTopLevelCommas(selectText) + if (selectParts.length === 0) return null + if (selectParts.some((p) => p.trim() === '*' || /\.\s*\*\s*$/.test(p))) return null + + // ── FROM ── + const fromText = clauses['FROM'] + const fromTokens = scanTopLevel(fromText, [ + 'INNER JOIN', + 'LEFT OUTER JOIN', + 'RIGHT OUTER JOIN', + 'FULL OUTER JOIN', + 'CROSS APPLY', + 'OUTER APPLY', + 'CROSS JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'FULL JOIN', + 'JOIN', + ]) + + // Alias opsiyonel: verilmezse nesne adi alias olarak kullanilir. + const namedSourceRe = new RegExp( + String.raw`^\s*((?:${IDENT_PART}\s*\.\s*)?${IDENT_PART})(?:\s+(?:AS\s+)?(${IDENT_PART}))?\s*$`, + 'i', + ) + const aliasOnlyRe = new RegExp(String.raw`^\s*(?:AS\s+)?(${IDENT_PART})\s*$`, 'i') + + const makeSource = ( + partial: Partial & { schemaName: string; objectName: string; alias: string }, + index: number, + ): ViewSource => ({ + id: newId(), + joinKind: 'INNER', + conditions: [], + ...nextSourcePosition(index), + collapsed: false, + showSelectedOnly: false, + visibleRows: BOX_DEFAULT_ROWS, + ...partial, + }) + + /** Bir kaynak parcasini okur; alt sorgu ise govdesini saklar. */ + const readSource = ( + text: string, + joinKind: JoinKind, + index: number, + applyKind?: ApplyKind, + ): ViewSource | null => { + const trimmed = text.trim() + if (trimmed.startsWith('(')) { + const block = readParenBlock(trimmed, 0) + if (!block) return null + const aliasM = aliasOnlyRe.exec(trimmed.slice(block.end)) + if (!aliasM) return null + const alias = unquoteIdent(aliasM[1]) + const derivedSql = block.inner.replace(/^[ \t]*\n/, '').replace(/\s+$/, '') + return makeSource( + { + schemaName: '', + objectName: alias, + alias, + joinKind, + applyKind, + derivedSql, + derivedColumns: derivedOutputNames(block.inner), + }, + index, + ) + } + const m = namedSourceRe.exec(trimmed) + if (!m) return null + const parts = splitQualified(m[1]) + if (!parts) return null + const objectName = parts[parts.length - 1] + const schemaName = parts.length > 1 ? parts[parts.length - 2] : 'dbo' + return makeSource( + { + schemaName, + objectName, + alias: m[2] ? unquoteIdent(m[2]) : objectName, + joinKind, + applyKind, + }, + index, + ) + } + + const firstText = fromText.slice(0, fromTokens[0]?.start ?? fromText.length) + const firstSource = readSource(firstText, 'INNER', 0) + if (!firstSource) return null + const sources: ViewSource[] = [firstSource] + + const kindOf = (key: string): JoinKind => { + if (key.startsWith('LEFT')) return 'LEFT' + if (key.startsWith('RIGHT')) return 'RIGHT' + if (key.startsWith('FULL')) return 'FULL' + if (key.startsWith('CROSS')) return 'CROSS' + return 'INNER' + } + + /** + * Kolon niteleyicisini kaynaga baglar: once alias, sonra nesne adi, + * sonra sema.nesne. SSMS'in urettigi `dbo.Tablo.Kolon` bicimi de boylece cozulur. + */ + const resolveSource = (qualifier: string[]): ViewSource | undefined => { + const lower = qualifier.map((p) => p.toLowerCase()) + const last = lower[lower.length - 1] + const byAlias = sources.find((s) => s.alias.toLowerCase() === last) + if (byAlias) return byAlias + if (lower.length >= 2) { + const schema = lower[lower.length - 2] + const byFull = sources.find( + (s) => s.objectName.toLowerCase() === last && s.schemaName.toLowerCase() === schema, + ) + if (byFull) return byFull + } + return sources.find((s) => s.objectName.toLowerCase() === last) + } + + /** "dbo.T.Col" / "a.Col" -> kaynak + kolon adi. */ + const resolveColumnRef = (raw: string): { source: ViewSource; column: string } | null => { + const parts = splitQualified(raw) + if (!parts || parts.length < 2) return null + const column = parts[parts.length - 1] + const source = resolveSource(parts.slice(0, -1)) + if (!source) return null + return { source, column } + } + + for (let i = 0; i < fromTokens.length; i++) { + const tok = fromTokens[i] + const segment = fromText.slice(tok.end, fromTokens[i + 1]?.start ?? fromText.length) + + if (tok.key === 'CROSS APPLY' || tok.key === 'OUTER APPLY') { + const src = readSource( + segment, + 'INNER', + sources.length, + tok.key.startsWith('CROSS') ? 'CROSS' : 'OUTER', + ) + if (!src) return null + sources.push(src) + continue + } + + const kind = kindOf(tok.key) + if (kind === 'CROSS') { + const src = readSource(segment, 'CROSS', sources.length) + if (!src) return null + sources.push(src) + continue + } + + const onTok = scanTopLevel(segment, ['ON'])[0] + if (!onTok) return null + const src = readSource(segment.slice(0, onTok.start), kind, sources.length) + if (!src) return null + const onText = segment.slice(onTok.end) + if (scanTopLevel(onText, ['OR']).length > 0) return null + const pieces = splitByTokens(onText, scanTopLevel(onText, ['AND'])) + + sources.push(src) + for (const piece of pieces) { + const cmp = findTopLevelComparison(piece) + if (!cmp) return null + const left = resolveColumnRef(piece.slice(0, cmp.start)) + const right = resolveColumnRef(piece.slice(cmp.end)) + if (!left || !right) return null + // Sag taraf join edilen kaynak olmali; degilse taraflari cevir. + let leftRef = left + let rightRef = right + if (right.source.id !== src.id) { + if (left.source.id !== src.id) return null + leftRef = right + rightRef = left + } + if (leftRef.source.id === src.id) return null + src.conditions.push({ + id: newId(), + leftSourceId: leftRef.source.id, + leftColumn: leftRef.column, + operator: piece.slice(cmp.start, cmp.end).trim() as JoinOperator, + rightColumn: rightRef.column, + }) + } + } + + // ── SELECT listesi -> criteria satirlari ── + const rows: CriteriaRow[] = [] + const aliasRe = new RegExp(String.raw`\s+AS\s+(${IDENT_PART})\s*$`, 'i') + + for (const part of selectParts) { + let expr = part + let outAlias = '' + const am = aliasRe.exec(part) + if (am) { + outAlias = unquoteIdent(am[1]) + expr = part.slice(0, am.index) + } + const { mode, inner } = peelAggregate(expr) + const ref = resolveColumnRef(inner) + if (ref) { + rows.push({ + id: newId(), + sourceId: ref.source.id, + columnName: ref.column, + alias: outAlias && outAlias !== ref.column ? outAlias : '', + output: true, + groupMode: mode, + sortType: '', + sortOrder: 0, + filters: [''], + }) + continue + } + // Kolon referansi degilse serbest ifade olarak saklanir. + if (!outAlias) return null + rows.push({ + id: newId(), + sourceId: '', + columnName: '', + expression: expr.trim(), + alias: outAlias, + output: true, + groupMode: 'GroupBy', + sortType: '', + sortOrder: 0, + filters: [''], + }) + } + + /** Filtre satirini bul, yoksa ciktisiz bir satir olustur. */ + const findOrCreateRow = ( + key: { sourceId: string; columnName: string } | { expression: string }, + mode: GroupMode, + ): CriteriaRow => { + const isExpr = 'expression' in key + const existing = rows.find((r) => + isExpr + ? r.expression !== undefined && r.expression === key.expression && r.groupMode === mode + : r.expression === undefined && + r.sourceId === key.sourceId && + r.columnName.toLowerCase() === key.columnName.toLowerCase() && + r.groupMode === mode, + ) + if (existing) return existing + const created: CriteriaRow = { + id: newId(), + sourceId: isExpr ? '' : key.sourceId, + columnName: isExpr ? '' : key.columnName, + ...(isExpr ? { expression: key.expression } : {}), + alias: '', + output: false, + groupMode: mode === 'GroupBy' ? 'Where' : mode, + sortType: '', + sortOrder: 0, + filters: [''], + } + rows.push(created) + return created + } + + /** + * Bir yuklemin basindaki referansi ayirir; geri kalani SSMS'teki Filter + * hucresinin serbest metnidir. "a.[X] > 100" -> ref + "> 100" + */ + const splitPredicate = (raw: string): { refText: string; predicate: string } | null => { + const opTokens = scanTopLevel(raw, [ + 'IS NOT NULL', + 'IS NULL', + 'NOT BETWEEN', + 'BETWEEN', + 'NOT LIKE', + 'LIKE', + 'NOT IN', + 'IN', + ]) + if (opTokens.length > 0) { + const first = opTokens[0] + return { refText: raw.slice(0, first.start), predicate: raw.slice(first.start).trim() } + } + const cmp = findTopLevelComparison(raw) + if (!cmp) return null + return { refText: raw.slice(0, cmp.start), predicate: raw.slice(cmp.start).trim() } + } + + /** + * WHERE / HAVING metnini SSMS grid semantigine geri cevirir: + * en ust seviye OR'lar sutunlara, icindeki AND'ler satirlara karsilik gelir. + */ + const readPredicate = (text: string, target: 'where' | 'having'): boolean => { + const orGroups = splitByTokens(text, scanTopLevel(text, ['OR'])) + for (let oi = 0; oi < orGroups.length; oi++) { + let group = orGroups[oi].trim() + // Tum grubu saran parantezi ac. + if (group.startsWith('(') && group.endsWith(')')) { + const block = readParenBlock(group, 0) + if (block && block.end === group.length) group = block.inner.trim() + } + const conjuncts = splitByTokens(group, scanTopLevel(group, ['AND'])) + for (const conj of conjuncts) { + const raw = conj.trim() + if (!raw) return false + const split = splitPredicate(raw) + if (!split) return false + const { mode, inner } = peelAggregate(split.refText) + if (target === 'having' && !isAggregateMode(mode)) return false + const ref = resolveColumnRef(inner) + const row = ref + ? findOrCreateRow({ sourceId: ref.source.id, columnName: ref.column }, mode) + : findOrCreateRow({ expression: inner.trim() }, mode) + while (row.filters.length <= oi) row.filters.push('') + if (row.filters[oi]) return false // ayni hucreye iki kosul dusemez + row.filters[oi] = split.predicate + } + } + return true + } + + if (clauses['WHERE'] && !readPredicate(clauses['WHERE'], 'where')) return null + if (clauses['HAVING'] && !readPredicate(clauses['HAVING'], 'having')) return null + + // Filtre dizilerini esit uzunluga tamamla. + const maxFilters = Math.max(1, ...rows.map((r) => r.filters.length)) + rows.forEach((r) => { + while (r.filters.length < maxFilters) r.filters.push('') + }) + + // ── GROUP BY dogrulama ── + if (clauses['GROUP BY']) { + for (const g of splitTopLevelCommas(clauses['GROUP BY'])) { + const ref = resolveColumnRef(g) + const found = ref + ? rows.find( + (r) => + r.expression === undefined && + r.sourceId === ref.source.id && + r.columnName.toLowerCase() === ref.column.toLowerCase() && + r.groupMode === 'GroupBy', + ) + : rows.find((r) => r.expression === g.trim() && r.groupMode === 'GroupBy') + if (!found) return null + } + } + + // ── ORDER BY ── + if (clauses['ORDER BY']) { + const orderParts = splitTopLevelCommas(clauses['ORDER BY']) + for (let i = 0; i < orderParts.length; i++) { + const m = /^([\s\S]+?)(?:\s+(ASC|DESC))?\s*$/i.exec(orderParts[i].trim()) + if (!m) return null + const { mode, inner } = peelAggregate(m[1]) + const ref = resolveColumnRef(inner) + const target = ref + ? rows.find( + (r) => + r.expression === undefined && + r.sourceId === ref.source.id && + r.columnName.toLowerCase() === ref.column.toLowerCase() && + r.groupMode === mode, + ) + : rows.find((r) => r.expression === inner.trim() && r.groupMode === mode) + if (!target) return null + target.sortType = (m[2]?.toUpperCase() as 'ASC' | 'DESC') || 'ASC' + target.sortOrder = i + 1 + } + // Uretici ORDER BY varken TOP (100) PERCENT ekler; bunu kullaniciya + // acik bir TOP olarak gostermemek gerekir. + if (topEnabled && topPercent && topCount === '100') { + topEnabled = false + topPercent = false + } + } + + return { + sources, + rows, + settings: { + schemaName: viewSchema, + viewName, + distinct, + // GROUP BY clause'u ya da bir aggregate varsa Sigma acik gelir. + groupBy: !!clauses['GROUP BY'] || rows.some((r) => isAggregateMode(r.groupMode)), + topEnabled, + topCount, + topPercent, + }, + } +}