From ad00e759113d2ebc305952c2def64bdd143addee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:43:46 +0300 Subject: [PATCH] =?UTF-8?q?Wizard=20Import=20ve=20Export=20i=C5=9Flemi=20m?= =?UTF-8?q?=C3=BCkemmelle=C5=9Ftirildi.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/instructions/ai.instructions.md | 83 +- .github/instructions/lowcode.instructions.md | 100 +- README.md | 127 ++- .../Wizard/IListFormWizardAppService.cs | 8 +- .../ListForms/Wizard/WizardExportDtos.cs | 34 + .../ListForms/Wizard/WizardImportDtos.cs | 96 ++ .../ListForms/ListFormWizardAppService.cs | 927 ++++++++++++------ .../ListForms/SqlCreateTableParser.cs | 329 +++++++ .../ListForms/SqlTableSchemaComparer.cs | 200 ++++ .../ListForms/WizardExportCollector.cs | 511 ++++++++++ .../ListForms/WizardImportValidator.cs | 582 +++++++++++ .../Migrations/CrudDataSeeder.cs | 134 +-- .../Migrations/LanguagesData.json | 174 +++- .../Enums/WizardImportIssueSeverityEnum.cs | 17 + .../Data/CrudEndpointSeedApplier.cs | 267 +++++ claude.md | 42 +- .../App.DeveloperKit.CustomComponents.json | 2 +- .../components/shared/DbMigrateLogPanel.tsx | 2 +- ui/src/proxy/admin/wizard/models.ts | 78 +- ui/src/services/wizard.service.ts | 17 +- .../listForm/wizard/WizardFileManager.tsx | 108 +- .../listForm/wizard/WizardImportDialog.tsx | 513 +++++++++- .../developerKit/SqlTableDesignerDialog.tsx | 29 +- 23 files changed, 3857 insertions(+), 523 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardExportDtos.cs create mode 100644 api/src/Sozsoft.Platform.Application/ListForms/SqlCreateTableParser.cs create mode 100644 api/src/Sozsoft.Platform.Application/ListForms/SqlTableSchemaComparer.cs create mode 100644 api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs create mode 100644 api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs create mode 100644 api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportIssueSeverityEnum.cs create mode 100644 api/src/Sozsoft.Platform.Domain/Data/CrudEndpointSeedApplier.cs diff --git a/.github/instructions/ai.instructions.md b/.github/instructions/ai.instructions.md index e67f3d02..38552009 100644 --- a/.github/instructions/ai.instructions.md +++ b/.github/instructions/ai.instructions.md @@ -657,8 +657,12 @@ a deploy. AI must learn and teach implementation flow primarily from these asset - LanguagesData.json - WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read surface B below -- ListFormSeedDataApplier (Domain/Data/) — applies `data/{ListFormCode}.json` after every - contributor has run +- ListFormSeedDataApplier, CrudEndpointSeedApplier (Domain/Data/) — the single home of the apply + rules; both the migrate/seed flow and the wizard import's completion call the same services. + `data/` rows are inserted-only on migrate (a possibly stale file must not overwrite live data); + `crud/` entries are timestamp-guarded — an existing record is updated or deleted only when the + file's `GeneratedAt` is newer than the record's `LastModificationTime`/`CreationTime`. **Migrate + adds; it does not overwrite.** Follow this rule in any new seeder. **B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through `SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer, the CRUD @@ -893,17 +897,65 @@ Display names are read from the language key whose name equals the group name. Exporting a wizard produces a zip whose paths are relative to the scope folder (`host` / `tenants/{tenantId}`) and which carries every dependency: -- `wizard/{file}.json` +The closure is **recursive** (`WizardExportCollector`): a sub-form's wizard file is queued and its +own dependencies are collected under the same rules, so the archive stands on its own. + +- `wizard/{file}.json` — the screen itself plus the wizard files of the sub-forms it references + through `SubFormsDto[].Code`, recursively - `data/App.DeveloperKit.CustomComponents.json` — `Custom` path; the file is filtered down to the exported component and its dependency chain, never exported whole +- `data/{ListFormCode}.json` — the screen's own data mirror when `SeedFilePath` is set - `crud/{entity}.json` — endpoints those components use -- `{sql|postgres}/{object|execute}/{object}.sql` — the object behind `SelectCommand` on the - `List` path +- `{sql|postgres}/{object|execute}/{object}.sql` — the object behind `SelectCommand`, the objects + a lookup query (`LookupDto.LookupQuery`) references through `FROM`/`JOIN`, and on the `Custom` + path the tables the CRUD endpoints sit on -Import is two-phase: `AnalyzeImport` (unzip to `.imports/{id}/staged`, classify each entry as -`New` / `Identical` / `Conflict`, conflicts resolved in a diff editor) then `ApplyImport` -(file-by-file write, previous content backed up so `RollbackImport` can undo the whole batch), -closed by `CompleteImport`. +Script files are never guessed from a fixed provider/folder combination: the scope's script folders +are scanned from disk, and every file matching the object name is added. A missing script is **not** +a warning — not every table is seed-managed (ABP's own tables come from EF migrations); whether the +target has it is decided by the import analysis against the real database, never assumed here. + +An unresolved **recorded reference** (a sub-form, a `SeedFilePath` mirror, a `DataSources[].SeedFile`, +a component row named in a seed file but with no counterpart) does not block the download but is +never silent: `ExportAnalysis` returns the full archive contents plus what is missing, and the File +Manager shows that list before downloading. + +The import screen is a four-step wizard (`Steps`): **Files** (resolve conflicts) → **Checks** +(findings; a schema finding opens the existing `SqlTableDesignerDialog`, and the analysis re-runs +after a deploy) → **Plan** (the full list of what confirming will do, in the server's completion +order) → **Apply**. Going back is always allowed; going forward is gated — unresolved conflicts +block Checks, a blocking finding blocks Plan. + +Server-side it is two phases: `AnalyzeImport` (unzip to `.imports/{id}/staged`, classify each entry +as `New` / `Identical` / `Conflict`) then `ApplyImport` (file-by-file write, previous content backed +up so `RollbackImport` can undo the whole batch), closed by `CompleteImport`. + +`AnalyzeImport` also **validates** before anything is written (`WizardImportValidator`). With at +least one `Error` finding the session is marked `blocked` on the server and `ApplyImport` refuses, +so the check cannot be bypassed by calling the endpoint directly. Errors: malformed JSON; a wizard +file with no `Wizard`/`ListForm` section; a screen's or a `crud/{Entity}.json`'s table missing in +the target **and** absent from the archive's scripts; a `data/{code}.json` whose ListForm exists +neither in the archive nor in the target; an unresolvable custom-component `Dependencies` / +`DataSources[].SeedFile`; an empty component name or route on the `Custom` path; a sub-form +reference with no counterpart. Warnings: object existence could not be queried, provider mismatch +(`sql` ↔ `postgres`), a missing `SeedFilePath` data file, an empty file. + +The analysis also **compares the table schema**. Generated table scripts are wrapped in +`IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END`, so when the target already has the table the +`CREATE` block is skipped entirely and a new column never arrives from the script itself — the screen +would then fail with an invalid-column error. The incoming `CREATE TABLE` declaration (`SqlCreateTableParser`) is +compared against the live `INFORMATION_SCHEMA.COLUMNS` shape. The split follows the risk of loss: +**a missing column is added** — adding is lossless, so it is reported as a Warning with the statement +it will run (`WizardImportIssueDto.Statement`) and applied at completion; the one exception is a +`NOT NULL` column with no default on a non-empty table, which the database itself would reject and +where the value to write is a business decision — that blocks. **An existing column is never +changed**: a length / precision / scale / nullability difference is an Error and stops the import, +because narrowing truncates and a type conversion can fail. An extra column in the target is a +Warning and is never dropped; no path emits `DROP TABLE`, `DROP COLUMN` or `ALTER COLUMN`. The +comparison lives in one place (`SqlTableSchemaComparer`) and both the analysis and the completion +use it. Type **names** are deliberately not compared: the provider catalog reports its own +canonical name (`VARCHAR` → `character varying` on PostgreSQL), so a textual comparison would raise +a false difference on every `VARCHAR` column. A `data/*.json` entry is compared and merged **by row, not by file**: the diff shows only the rows whose keys the archive carries (file-level `GeneratedAt`/`Order` are excluded), so unrelated @@ -919,9 +971,16 @@ soft deleted is revived by clearing the columns the screen's `DeleteCommand` set reports the inserted/updated/skipped counts. Data files that were already identical to the target are applied too: there was nothing to write, but the rows may be missing or soft deleted in the database. A file the user chose to keep (different, not written) is left alone. This step is not -covered by `RollbackImport`, which only restores files and must be run before it. The other -folders (`wizard`, `crud`, `sql`) stay as files and reach the database through the seeders or a -wizard deploy. +covered by `RollbackImport`, which only restores files and must be run before it. + +`CompleteImport` runs in **dependency order and the order is fixed**: (a) the `.sql` scripts under +`{sql|postgres}/{object,execute}` (`object` first) — a missing table is created here, otherwise the +later steps would fail on it; (b) columns missing from existing tables are added; (c) `crud/*.json` +through `ICrudEndpointSeedApplier`; (d) the `data/*.json` rows; (e) `wizard/*.json` — the screens +are installed through the same path the wizard screen uses (`DeployWizardAsync`), reusing the file's +`ListForm`/`Fields` contract as-is instead of rebuilding it from the wizard answers. A screen that +already exists is left untouched, and each screen is installed in its own unit of work. No migrate +run is needed: the screen works as soon as the import finishes. Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` / `execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries. diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index fe9ac9bc..1f554a8d 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -2447,27 +2447,94 @@ Nasıl bağlanıyorlar: ### 7.7 Taşıma Bir ekranı başka ortama taşımak tek dosya kopyalamak değildir. Wizard File Manager'ın **Export** -butonu zip üretir: +butonu, ekranın **özyinelemeli bağımlılık kapanışını** (`WizardExportCollector`) zip'e koyar: ``` -wizard/{dosya}.json +wizard/{dosya}.json (ekranın kendisi + SubFormsDto[].Code ile bağlı + alt formların wizard dosyaları, özyinelemeli) data/App.DeveloperKit.CustomComponents.json (component + bağımlılıklarına süzülmüş kopya) -crud/{entity}.json (component'lerin kullandığı endpoint'ler) -{sql|postgres}/{object|execute}/{nesne}.sql (List yolunda SelectCommand'ın nesnesi) +data/{ListFormCode}.json (ekranın SeedFilePath'i doluysa kendi veri aynası) +crud/{entity}.json (component'lerin/nesnenin kullandığı endpoint'ler) +{sql|postgres}/{object|execute}/{nesne}.sql (SelectCommand'ın nesnesi + lookup sorgularının + FROM/JOIN nesneleri + Custom yolunda CRUD + endpoint'lerinin dayandığı tablolar) ``` -Import üç adımlıdır: analiz (`New`/`Identical`/`Conflict`, çakışmalar diff editörde çözülür; +Script dosyaları sabit kombinasyondan tahmin edilmez; kapsam kökündeki script klasörleri diskten +taranır. Script bulunamaması uyarı üretmez — her tablo seed ile yönetilmez (ABP tabloları EF +migration'larıyla kurulur); "hedefte var mı" kararını import analizi verir. Çözülemeyen bir +**kayıtlı referans** (alt form, `SeedFilePath`, `DataSources[].SeedFile`, component satırı) +indirmeyi engellemez ama sessiz de kalmaz: `ExportAnalysis` ucu arşivin tam içeriğini ve eksikleri +döndürür, eksik varsa File Manager indirmeden önce listeyi gösterir. + +Import ekranı dört adımlı bir sihirbazdır (`Steps`): **Dosyalar** (çakışma çözümü) → **Kontroller** +(bulgular; tabloya dokunan bulguda `SqlTableDesignerDialog` açılır — şema farkında düzenleme, +eksik tabloda `initialTableName` ile oluşturma modunda; deploy sonrası analiz yenilenir) → +**Yapılacaklar** (onaydan önce ne olacağının tam listesi) → **Uygulama**. Geriye dönüş serbest, +ileri geçiş koşulludur: çözülmemiş çakışma varken Kontroller'e, engelleyici bulgu varken +Yapılacaklar'a geçilmez. + +Engelleyici bulgular kaynağına göre ikiye ayrılır (`WizardImportIssueDto.RequiresReexport`): +arşivin kendi eksikliği/bozukluğu — bozuk JSON, eksik `Wizard`/`ListForm` bölümü, karşılığı olmayan +alt form / bağımlılık / component / script — hedef ortamda **giderilemez**, eksik olan veri arşivde +yoktur; tek çözüm kaynak ortamda export'u tekrarlamaktır ve UI bunları ayrı başlıkta, düzeltme +düğmesi olmadan gösterir. `WizardImportValidator.Add` üzerinden geçen her `Error` bu sınıftadır; +ortamda giderilebilen engeller (eksik tablo, şema farkı) bulgularını kendileri kurar. Export önizlemesi +de aynı dili konuşur: çözülemeyen bir kayıtlı referans varsa arşivin import'ta reddedileceğini söyler. + +Import **toplu bir işlemdir**; engelleme oturum genelidir, dosya bazlı değildir. Tek bir engelleyici +bulgu tüm zip'i durdurur ve dosya seçimini kaldırarak aşılamaz — yarım kurulumu önleyen budur. +Bu yüzden tabloya dair her engelin ekran içinde bir çıkış yolu olmak zorundadır: yeni bir engelleyici +bulgu türü eklerken bulguya `TableName` (+ tablo hedefte yoksa `TableMissing`) yazılır, yoksa +kullanıcı diyalogdan çıkmadan ilerleyemez. + +Sunucu tarafı üç işlemdir: **analiz** (`New`/`Identical`/`Conflict`, çakışmalar diff editörde çözülür; `data/*.json` satır bazlı karşılaştırılır — yalnızca zip'in taşıdığı anahtarlar, dolayısıyla ilgisiz component'ler fark olarak görünmez) → -uygulama (dosya dosya yazım, önceki hâl yedeklenir, `RollbackImport` ile toplu geri alma) → -kapanış (`CompleteImport`: yazılan `data/*.json` dosyaları `ListFormSeedDataApplier` ile -veritabanına uygulanır — tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle -güncellenir, soft delete edilmiş satırın `IsDeleted`/`DeletionTime`/`DeleterId` izleri temizlenir. Bu adım rollback kapsamında değildir; `wizard`/`crud`/`sql` dosyaları seeder ya da -deploy bekler). +**uygulama** (dosya dosya yazım, önceki hâl yedeklenir, `RollbackImport` ile toplu geri alma) → +**kapanış** (`CompleteImport`). + +Kapanış bağımlılık sırasıyla çalışır ve sıra sabittir: **(a)** `{sql|postgres}/{object,execute}/*.sql` +(`object` önce) — tablo yoksa burada oluşur, aksi halde sonraki adımlar eksik tablo yüzünden +düşerdi; **(b)** hedefte eksik kolonlar eklenir; **(c)** `crud/*.json` → `ICrudEndpointSeedApplier`; +**(d)** `data/*.json` → +`ListFormSeedDataApplier` (tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle +güncellenir, soft delete edilmiş satırın `IsDeleted`/`DeletionTime`/`DeleterId` izleri temizlenir). +**(e)** `wizard/*.json` → ekran kurulur. Kurulum wizard ekranıyla aynı yoldan geçer +(`ListFormWizardAppService.DeployWizardAsync`); dosyadaki `ListForm`/`Fields` sözleşmesi olduğu gibi +kullanılır, wizard cevaplarından yeniden türetilmez (türetmek tasarımcı değişikliklerini silerdi). +Hedefte zaten var olan ekrana dokunulmaz ve her ekran kendi UoW'unda kurulur. Bu adım rollback +kapsamında değildir. Migrate beklenmez; import bittiğinde ekran çalışır durumdadır. + +**Analiz doğrulama da yapar** (`WizardImportValidator`) — dosyalar yazılmadan önce. En az bir +`Error` varken import başlatılamaz; sunucu oturuma `blocked` işaret dosyası bırakır, dolayısıyla +`ApplyImport` doğrudan çağrılsa bile reddeder. Error: bozuk JSON; `Wizard`/`ListForm` bölümü eksik +wizard dosyası; ekranın ya da bir `crud/{Entity}.json`'un tablosu hedefte yok **ve** zip onu +oluşturan scripti taşımıyor; `data/{code}.json`'un ekranı ne zip'te ne hedefte var; custom +component'in `Dependencies`/`DataSources[].SeedFile` bağımlılığı bulunamıyor; Custom wizard'da +bileşen adı ya da rota boş; alt form referansı karşılıksız. Warning: nesne varlığı sorgulanamadı, +sağlayıcı uyuşmazlığı (`sql` ↔ `postgres`), `SeedFilePath` veri dosyası yok, boş dosya. +Eksik tablo ölçütü "veritabanında var **ya da** zip onu oluşturan scripti taşıyor"dur. + +**Şema karşılaştırması.** Tablo script'leri `IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END` +ile sarılı olduğu için hedefte tablo varsa `CREATE` bloğu atlanır ve yeni kolon kendiliğinden gelmez; +ekran açılışta "geçersiz kolon" hatası verir. Analiz bunu yakalar: script'teki `CREATE TABLE` +bildirimi (`SqlCreateTableParser`) hedefin `INFORMATION_SCHEMA.COLUMNS` şemasıyla karşılaştırılır. +Ayrım kayıp riskine göredir: **eksik kolon eklenir** (kayıpsız) — Warning olarak bildirilir, +çalıştırılacak cümle gösterilir (`WizardImportIssueDto.Statement`) ve kapanışta uygulanır; tek +istisna varsayılanı olmayan `NOT NULL` kolonun dolu tabloya eklenmesidir, o durumda durulur. +**Var olan kolon değiştirilmez** — uzunluk/kesinlik/ondalık/nullability farkı Error'dur ve import'u +durdurur; daraltma veri keser, tip dönüşümü başarısız olabilir. Hedefteki fazla kolon Warning'dir, +asla düşürülmez; hiçbir yol `DROP TABLE`/`DROP COLUMN`/`ALTER COLUMN` üretmez. Tip **adı** +karşılaştırılmaz: sağlayıcı kataloğu kanonik adı raporlar (PG'de `VARCHAR` → `character varying`) +ve metin karşılaştırması sahte fark üretirdi. Karşılaştırma tek yerdedir +(`SqlTableSchemaComparer`); analiz ve kapanış aynı sonucu kullanır. + `data/*.json` dosyaları kapsamdaki tüm satırları taşıdığı için üzerine yazılmaz: satırlar anahtar alan üzerinden birleştirilir (gelen satır varsa güncellenir, yoksa eklenir, hedefteki diğer satırlar korunur). Sınırlar: yalnızca `wizard`, `crud`, `data`, `sql`, `postgres` kök klasörleri (SQL sağlayıcı -klasörlerinin altında yalnızca `object`/`execute`); 5 MB/dosya, 50 MB/arşiv, 500 girdi. +klasörlerinin altında yalnızca `object`/`execute`); 5 MB/dosya, 50 MB/arşiv, 200 MB açılmış toplam +(zip bomb sınırı okuma sırasında da uygulanır), 500 girdi. Yetkiler: `App.Listforms.Wizard.Export` / `.Import`. ### 7.8 Event Script — düğüm olay script'leri @@ -2787,7 +2854,8 @@ konfigürasyon ekranlarını (9.9–9.14) kapsar. ### 9.1 CRUD Endpoint seed dosyası -Konum: `configs/seeds/{kapsam}/crud/{EntityName}.json` · Okuyan: `CrudDataSeeder` +Konum: `configs/seeds/{kapsam}/crud/{EntityName}.json` · Okuyan: `CrudEndpointSeedApplier` +(migrate'te `CrudDataSeeder`, import kapanışında `CompleteImport` üzerinden — kural tek yerdedir) ```jsonc { @@ -2805,6 +2873,14 @@ Konum: `configs/seeds/{kapsam}/crud/{EntityName}.json` · Okuyan: `CrudDataSeede Üretilen C# kodu dosyada tutulmaz; entity adı ve operasyondan yeniden türetilir. +**Uygulama politikası damga karşılaştırmalıdır.** Eksik endpoint her zaman eklenir. Mevcut bir +kayıt yalnızca dosyanın `GeneratedAt` damgası kaydın `LastModificationTime`/`CreationTime` +değerinden **yeniyse** güncellenir; dosyada artık yer almayan bir operasyon yalnızca aynı koşulda +silinir. Aksi halde kayıt korunur ve log'a drift uyarısı düşer. Amaç: CRUD Endpoint Manager'dan +yapılan bir revizyon, depodan gelen eski bir dosya yüzünden migrate sırasında geri alınmasın; +gerçekten yeni bir dosya (başka ortamda üretilmiş, import edilmiş) ise değişiklik yayılsın. +`GeneratedAt` taşımayan bir dosya hiçbir zaman yeni sayılmaz. + - Üretim/aktivasyon yeri: SQL Query Manager nesne gezgininde tablo satırının **CRUD Endpoints** aksiyonu, toolbar'daki toplu üretim, tablo tasarımcısının deploy öncesi adımı ve Wizard'ın veri ayarları adımı — hepsi aynı `CrudEndpointDialog`. diff --git a/README.md b/README.md index 08ea13db..0b244848 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Aktif veritabanı sağlayıcısına göre `sql/` (SQL Server) ya da `postgres/` | `{sql\|postgres}/object` | Seed sırasında nesneyi oluşturur/günceller. SqlTableDesigner ve SqlQueryManager, deploy ettiği script'i çalıştığı kapsamın bu klasörüne yazar (host bağlamı → `host/`, tenant seçiliyken → `tenants/{tenantId}/`). | | `{sql\|postgres}/execute` | Nesneyi oluşturur; ayrıca tüm migration ve seed'ler bittikten sonra `AfterAllMigrationsSqlExecutor` dosya adından türettiği procedure'ü çalıştırır (dosya adı = procedure adı, `schema.Procedure.sql` de olur). | | `wizard` | ListForm Wizard'ın ürettiği `.json` konfigürasyonları; `WizardDataSeeder` kendi kapsamındaki dosyaları okur. | -| `crud` | CrudEndpoint konfigürasyonları; `CrudDataSeeder` kendi kapsamındaki dosyaları okur. | +| `crud` | CrudEndpoint konfigürasyonları; `CrudDataSeeder` kendi kapsamındaki dosyaları `ICrudEndpointSeedApplier` üzerinden uygular. Politika **damga karşılaştırmalıdır**: eksik endpoint her zaman eklenir, mevcut bir kayıt yalnızca dosyanın `GeneratedAt` damgası kaydın `LastModificationTime`/`CreationTime` değerinden **yeniyse** güncellenir ya da silinir; aksi halde kayıt korunur ve log'a drift uyarısı düşer. Böylece CRUD Endpoint Manager'dan yapılan bir revizyon depodan gelen eski bir dosya yüzünden migrate sırasında geri alınmaz. Damgası olmayan (eski biçimde yazılmış) dosya hiçbir zaman yeni sayılmaz. | | `data` | Ekranlardan girilen listelerin veri aynası (`{ListFormCode}.json`). `ListForm.SeedFilePath` dolu olan ekranda `SeedSyncInsert/Update/Delete` bayraklarından işaretli olan işlemler dosyaya yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında bütün contributor'lar bittikten sonra `ListFormSeedDataApplier` dosyayı geri uygular: anahtarı veritabanında **olmayan** satırları ekler, var olanlara dokunmaz. Aynı uygulayıcı wizard import'unun kapanışında da (`CompleteImport`) yalnızca o import'ta yazılan dosyalar için, `overwriteExisting` açık olarak çalışır — orada var olan satır dosyadaki değerlerle güncellenir. | Klasör kökünde kalmış eski `.sql` dosyaları geriye dönük uyumluluk için hâlâ işlenir, @@ -573,14 +573,51 @@ Bir ekranın tanımı, bağımlılıklarıyla birlikte tek bir zip olarak taşı kapsam klasörü (`host` / `tenants/{tenantId}`) kökü baz alınarak yazılır; arşiv başka bir ortamda aynı klasör düzenine doğrudan açılabilir. +Toplama **özyinelemelidir** (`WizardExportCollector`): alt formların wizard dosyaları kuyruğa +eklenir ve onların bağımlılıkları da aynı kurallarla toplanır. Böylece zip tek başına çalışır. + | Yol | İçerik | | --- | --- | -| `wizard/{dosya}.json` | Wizard tanımının kendisi | +| `wizard/{dosya}.json` | Wizard tanımının kendisi **ve** `SubFormsDto[].Code` ile bağlı alt form ekranlarının wizard dosyaları (özyinelemeli) | | `data/App.DeveloperKit.CustomComponents.json` | Custom yolunda bağlı component ve bağımlılıkları — dosyanın yalnızca bu satırlara süzülmüş kopyası | +| `data/{ListFormCode}.json` | Ekranın `SeedFilePath` değeri doluysa kendi veri aynası | | `crud/{entity}.json` | Component'lerin/nesnenin kullandığı CRUD endpoint tanımları | -| `{sql\|postgres}/{object\|execute}/{nesne}.sql` | List yolunda `SelectCommand`'a karşılık gelen SQL nesnesi | +| `{sql\|postgres}/{object\|execute}/{nesne}.sql` | `SelectCommand`'a karşılık gelen SQL nesnesi, lookup sorgularının (`LookupDto.LookupQuery`) `FROM`/`JOIN` ile işaret ettiği nesneler ve Custom yolunda CRUD endpoint'lerinin dayandığı tablolar | -**İçe aktarma üç adımlıdır** (`WizardImportDialog`): +Script dosyaları sabit bir sağlayıcı/klasör kombinasyonundan **tahmin edilmez**: kapsam kökündeki +script klasörleri (`sql/object`, `postgres/execute` …) diskten taranır ve nesne adıyla eşleşen her +dosya arşive girer. Yeni bir sağlayıcı ya da script klasörü eklendiğinde toplayıcı değişmez. + +Script bulunamaması **uyarı üretmez**: her tablo seed ile yönetilmez — ABP'nin kendi tabloları +(`AbpUsers`, `AbpRoles` …) EF migration'larıyla kurulur ve seed script'leri yoktur. "Bu tablo +hedefte var mı" sorusunun tek doğru cevabı hedef veritabanındadır; kararı import analizi verir, +export varsayımda bulunmaz. + +Çözülemeyen bir **kayıtlı referans** (seed dosyasında adı geçen ama karşılığı bulunmayan alt form, +`SeedFilePath` veri aynası, `DataSources[].SeedFile` CRUD dosyası, custom component satırı) +indirmeyi engellemez ama sessiz de kalmaz: `ExportAnalysis` ucu indirmeden önce arşivin tam +içeriğini ve eksikleri döndürür, Wizard File Manager eksik varsa önce bu listeyi gösterir. + +**İçe aktarma dört adımlı bir sihirbazdır** (`WizardImportDialog`, `Steps` bileşeni): + +| Adım | İçerik | Geçiş koşulu | +| --- | --- | --- | +| **Dosyalar** | Zip girdileri ve durumları; çakışanlar Monaco `DiffEditor` ile çözülür | Çözülmemiş çakışma kalmamalı | +| **Kontroller** | Analiz bulguları ve zip'te atlanan girdiler. Engelleyiciler iki başlıkta ayrılır — **arşiv eksik ya da bozuk** (burada giderilemez, kaynak ortamda export tekrarlanmalı; düzeltme düğmesi yoktur) ve **ortamda giderilebilir**. Tabloya dokunan bulgularda mevcut `SqlTableDesignerDialog` açılır — şema farkında **Tabloyu düzenle** (düzenleme modu), eksik tabloda **Tabloyu oluştur** (oluşturma modu, ad ön dolu); deploy sonrası analiz kendiliğinden yenilenir (önceki oturum bırakılır) | Engelleyici (`Error`) bulgu kalmamalı | +| **Yapılacaklar** | Onaylandığında ne olacağının tam listesi: yazılacak dosyalar, çalışacak SQL script'leri, eklenecek kolonlar (cümleleriyle), uygulanacak CRUD ve veri dosyaları, kurulacak ekranlar. Sıra sunucudaki kapanış sırasıyla aynıdır | Onay | +| **Uygulama** | Dosya dosya yazım, kapanış adımlarının sonucu | — | + +Geriye dönüş her zaman serbesttir; ileri atlama bağımlılık koşullarına bağlıdır. Adım başlıklarına +tıklanarak da gezinilebilir. + +İçe aktarma **toplu (batch) bir işlemdir**: engelleme oturum genelindedir, dosya bazlı değildir. +Bir tek engelleyici bulgu tüm zip'i durdurur ve o dosyanın işaretini kaldırarak aşılamaz — yarım +kurulum üretmemenin tek güvenli yolu budur. Karşılığında, tabloya dair her engel diyalogdan +çıkmadan giderilebilir (tablo tasarımcısı → deploy → analiz yenilenir). Tabloyla ilgisi olmayan +engeller (eksik alt form, eksik custom component bağımlılığı, bozuk JSON) zip'in kendisinin eksik +olduğunu gösterir; bunlar kaynakta düzeltilip yeniden export edilmelidir. + +Adımların altında yatan üç sunucu işlemi şunlardır: 1. **Analiz** (`AnalyzeImport`) — zip, seed kökü altındaki `.imports/{importId}/staged` klasörüne açılır (nokta ile başladığı için seeder taramalarına girmez) ve her dosya hedefteki karşılığıyla @@ -594,7 +631,13 @@ aynı klasör düzenine doğrudan açılabilir. 2. **Uygulama** (`ApplyImport`) — dosyalar tek tek yazılır, ilerleme satır satır gösterilir. Yazılan her dosyanın önceki hâli `.imports/{importId}/backup` altına alındığı için hata durumunda `RollbackImport` ile toplu işlem geri alınabilir. -3. **Kapanış** (`CompleteImport`) — oturum kapatılmadan önce zip'in getirdiği `data/*.json` dosyaları +3. **Kapanış** (`CompleteImport`) — oturum kapatılmadan önce zip'in getirdiği dosyalar + **bağımlılık sırasıyla** veritabanına uygulanır. Sıra bilinçli ve sabittir: + **(a)** `{sql|postgres}/{object,execute}/*.sql` scriptleri çalıştırılır (`object` önce) — tablo + yoksa burada oluşur, aksi halde sonraki iki adım eksik tablo yüzünden baştan düşerdi; + **(b)** hedefte eksik olan kolonlar eklenir (aşağıya bakın); + **(c)** `crud/*.json` dosyaları `ICrudEndpointSeedApplier` ile uygulanır; + **(d)** `data/*.json` dosyaları `IListFormSeedDataApplier.ApplyFilesAsync(..., overwriteExisting: true)` ile veritabanına uygulanır: tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle **güncellenir** (migrate/seed akışından farkı budur — orada dosya bayat olabileceği için canlı kayda @@ -607,8 +650,72 @@ aynı klasör düzenine doğrudan açılabilir. dediği (farklı olup yazılmayan) dosyaya dokunulmaz. Anahtarı olmayan satır güncellenemez, atlanır; eklenen/güncellenen/atlanan satır sayısı dialogda raporlanır. Bu adım `RollbackImport` - kapsamında **değildir** — geri alma bu adımdan önce yapılır. Diğer klasörler (`wizard`, `crud`, - `sql`) dosya olarak kalır; veritabanına seeder ya da wizard deploy ile geçer. + kapsamında **değildir** — geri alma bu adımdan önce yapılır. + +5. **Ekran tanımları** (`wizard/*.json`) — son adım: ekranın tablosu, CRUD ucu ve lookup verileri o + noktada hazırdır. Kurulum wizard ekranının kullandığı yolun aynısından geçer + (`DeployWizardAsync`), dosya ekranın üretilmiş `ListForm`/`Fields` sözleşmesini taşıdığı için + bunlar wizard cevaplarından **yeniden türetilmez** — tasarımcı üzerindeki her değişiklik olduğu + gibi kurulur. **Hedefte zaten var olan ekrana dokunulmaz.** Her ekran kendi işleminde kurulur: + bir dosyadaki hata yalnızca o ekranın kayıtlarını geri alır. İmport bittiğinde ekran çalışır + durumdadır, migrate beklemez; istemci ABP config'ini yeniler, böylece menü anında görünür. + +**Analiz doğrulama da yapar** (`WizardImportValidator`). Zip'in hedef ortamda yarım bir kurulum +bırakıp bırakmayacağı dosyalar yazılmadan **önce** denetlenir; bulgular `Error`/`Warning` olarak +raporlanır ve en az bir `Error` varken import başlatılamaz — sunucu oturuma `blocked` işaret +dosyası bırakır, dolayısıyla `ApplyImport` ucu doğrudan çağrılsa bile reddeder. + +| Kural | Ağırlık | +| --- | --- | +| Bozuk JSON (`wizard`, `crud`, `data`) | Error | +| Wizard bölümü ya da adı olmayan wizard dosyası; `ListForm` bölümü eksik List wizard'ı | Error | +| Ekranın tablosu (`SelectCommand`, `Table`/`View`) hedefte **yok** ve zip onu oluşturan `.sql` dosyasını **taşımıyor** | Error | +| `crud/{Entity}.json`'un tablosu hedefte yok ve zip'te oluşturan script yok | Error | +| `data/{code}.json`'un ekranı ne zip'te ne hedefte var | Error | +| Custom component'in `Dependencies` ya da `DataSources[].SeedFile` bağımlılığı ne zip'te ne hedefte var | Error | +| Custom wizard'da bileşen adı / rota yolu boş | Error | +| Alt form (`SubFormsDto[].Code`) ne zip'te ne hedefte var | Error | +| Nesne varlığı sorgulanamadı (bağlantı/izin) ve script de yok | Warning | +| Zip yalnızca karşı sağlayıcının (`sql` ↔ `postgres`) scriptlerini taşıyor | Warning | +| Ekranın `SeedFilePath` veri dosyası ne zip'te ne hedefte var; boş CRUD/veri dosyası | Warning | +| Script'in kurduğu tablo hedefte **var** ama bir kolonu eksik | Warning — kapanışta eklenir | +| Eksik kolon `NOT NULL`, varsayılanı yok ve tabloda kayıt var | Error (otomatik eklenemez) | +| Var olan kolonun uzunluk / kesinlik / ondalık / nullability değeri script'ten farklı | Error | +| Hedefte script'te olmayan fazladan kolon var | Warning (dokunulmaz) | + +Eksik tablo ölçütü "veritabanında var" değil, "veritabanında var **ya da** zip onu oluşturan +scripti taşıyor"dur — kapanış scriptleri veri satırlarından önce çalıştırdığı için zip'te scripti +olan tablo eksik sayılmaz. + +#### Şema karşılaştırması — tablo var ama yapısı farklıysa + +Üretilen tablo script'leri `IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END` kalıbıyla sarılıdır: +hedefte tablo varsa `CREATE` bloğu **tamamen atlanır**. Dolayısıyla eski şemalı bir tabloya yeni bir +kolon kendiliğinden gelmez; ekran o kolonu bir alan olarak tanımladığı için açılışta "geçersiz kolon" +hatası verir. Bu sessiz uyuşmazlığı analiz adımı yakalar (`SqlCreateTableParser` + +`WizardImportValidator`): zip'in getirdiği `CREATE TABLE` bildirimi, hedef tablonun +`INFORMATION_SCHEMA.COLUMNS` üzerinden okunan canlı şemasıyla karşılaştırılır. + +Ayrım **kayıp riskine** göre yapılır: + +- **Eksik kolon eklenir.** Kolon eklemek kayıpsız bir işlemdir; analiz "şu kolon eklenecek" diye + uyarır, çalıştırılacak cümleyi gösterir ve kapanışta (SQL script'lerinden sonra, CRUD ve veriden + önce) uygular. Tek istisna: `NOT NULL` olup varsayılan değeri olmayan bir kolon dolu bir tabloya + eklenemez — kolona hangi değerin yazılacağı bir iş kararıdır, bu durumda import durdurulur ve + cümle kullanıcıya bırakılır. Tablonun boş olup olmadığı çalışma anında sorgulanır; boş tabloya + böyle bir kolon güvenle eklenir. +- **Var olan kolon değiştirilmez.** Uzunluk/kesinlik/ondalık/nullability farkında import durur. + Daraltma veri keser, tip dönüşümü başarısız olabilir; doğru işlem veriye bakmayı gerektirir. + Fark somut olarak bildirilir ("uzunluk hedefte 200, script'te 300"), düzeltmeyi kullanıcı yapar. +- **Hiçbir kolon düşürülmez, hiçbir tablo `DROP` edilmez.** Hedefteki fazla kolon yalnızca + bilgilendirme olarak listelenir. + +Karşılaştırma **tip adına bakmaz**. Sağlayıcı kataloğu kendi kanonik adını raporlar (PostgreSQL'de +`VARCHAR` → `character varying`, `TIMESTAMP` → `timestamp without time zone`); script'teki yazılışla +metin olarak karşılaştırmak her `VARCHAR` kolonunda sahte fark üretirdi. Yalnızca her iki +sağlayıcıda da aynı anlamı taşıyan ölçüler karşılaştırılır: uzunluk, kesinlik/ondalık, nullability. +Script bir tablo kurmuyorsa (procedure, view) ya da `CREATE TABLE` çözülemiyorsa karşılaştırma +sessizce atlanır — tahmin yürütülmez. `data/*.json` dosyaları kapsamdaki bütün satırları taşıdığı için üzerine yazılmaz: satırlar anahtar alan üzerinden birleştirilir (gelen satır varsa günceller, yoksa eklenir, hedefteki diğer satırlar @@ -1197,8 +1304,8 @@ derlenip yayınlanır. Yeni bir modülün nasıl kurulacağını öğrenmek içi - `LanguagesData.json` — dil anahtarları (EN/TR) - `WizardDataSeeder.cs`, `CrudDataSeeder.cs` — aşağıdaki runtime seed dosyalarını okuyup uygulayan seeder'lar -- `ListFormSeedDataApplier` (`Domain/Data/`) — bütün contributor'lar bittikten sonra - `data/{ListFormCode}.json` dosyalarını uygular +- `ListFormSeedDataApplier`, `CrudEndpointSeedApplier` (`Domain/Data/`) — uygulama kuralının tek + yeri; hem migrate/seed akışı hem wizard import'unun kapanışı aynı servisleri çağırır **2. Runtime'da üretilen seed'ler** — `configs/seeds/`. Wizard, Custom Components ekranı/Visual Designer, CRUD Endpoint Manager ve `SeedFilePath` tanımlı ListForm ekranları çalışma zamanında @@ -1209,7 +1316,7 @@ geri yüklenir. Kapsam klasörü CDN düzeniyle aynıdır ve `SeedPathResolver` configs/seeds/ ├── host/ # host kapsamı │ ├── wizard/ # {Ad}.json → WizardDataSeeder -│ ├── crud/ # {EntityName}.json → CrudDataSeeder +│ ├── crud/ # {EntityName}.json → CrudEndpointSeedApplier │ ├── data/ # {ListFormCode}.json → ListFormSeedDataApplier │ ├── sql/{object,execute}/ # .sql (SQL Server) │ └── postgres/{object,execute}/ diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs index 9ab8c91f..d552751a 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs @@ -14,9 +14,15 @@ public interface IListFormWizardAppService /// Izin gruplarini iki dilli gorunen adlariyla dondurur. Task> GetPermissionGroups(); - /// Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur. + /// Wizard dosyasini ve bagimlilik kapanisindaki tum seed dosyalarini zip olarak dondurur. Task ExportFile(string fileName); + /// + /// Indirmeden once arsivin icerigini ve cozulemeyen bagimliliklarini dondurur; eksik bir + /// arsivin fark edilmeden baska bir ortama tasinmasini engeller. + /// + Task ExportAnalysis(string fileName); + /// /// Export zip'ini staging klasorune acar ve her dosya icin hedefteki karsiligiyla /// karsilastirma sonucunu dondurur. Bu adim hicbir hedef dosyaya dokunmaz. diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardExportDtos.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardExportDtos.cs new file mode 100644 index 00000000..6aeae15d --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardExportDtos.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Sozsoft.Platform.ListForms; + +/// Export zip'ine girecek tek bir seed dosyasi. +public class WizardExportEntryDto +{ + /// Kapsam klasorune goreli yol, ornegin sql/object/Prj_T_Task.sql. + public string Path { get; set; } + + /// Dosyanin arsive neden girdigi (wizard'in kendisi, alt form, lookup, veri...). + public string Reason { get; set; } +} + +/// +/// Indirmeden once gosterilen export onizlemesi. Zip'in tam icerigini ve cozulemeyen +/// bagimliliklari tasir; boylece eksik bir arsiv fark edilmeden baska bir ortama tasinmaz. +/// +public class WizardExportAnalysisDto +{ + /// Kaynak wizard seed dosyasinin adi. + public string FileName { get; set; } + + /// Indirilecek arsivin adi. + public string ArchiveName { get; set; } + + /// Kapsam klasoru (host ya da tenants/{tenantId}). + public string ScopeFolder { get; set; } + + public List Entries { get; set; } = []; + + /// Bagimlilik olarak bulunan ama diskte karsiligi olmayan dosyalar. + public List Warnings { get; set; } = []; +} diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs index f82c2143..72edda20 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs @@ -43,6 +43,66 @@ public class WizardImportAnalysisDto /// Guvenlik/format nedeniyle atlanan zip girdileri icin aciklamalar. public List Warnings { get; set; } = []; + + /// + /// Zip icerigi ile hedef ortam karsilastirildiginda bulunan tutarsizliklar. En az bir + /// varken dosya yazma adimi reddedilir. + /// + public List Issues { get; set; } = []; + + /// Import'u engelleyen bir bulgu var mi; istemci baslat dugmesini buna gore kapatir. + public bool HasBlockingIssues { get; set; } + + /// + /// Sema islerinin (script calistirma, kolon ekleme, tablo tasarimcisi) hedef aldigi veri + /// kaynagi. Seed script'leri kapsam duzeyindedir ve her zaman bu kaynakta calisir. + /// + public string DataSourceCode { get; set; } +} + +/// Import analizinde bulunan tek bir tutarsizlik. +public class WizardImportIssueDto +{ + public WizardImportIssueSeverityEnum Severity { get; set; } + + /// Bulgunun ilgili oldugu kapsam-goreli yol; genel bir bulguda bos kalir. + public string Path { get; set; } + + /// Kullaniciya gosterilen aciklama. + public string Message { get; set; } + + /// + /// Bulgunun karsiligi olan SQL cumlesi. Eksik kolon uyarilarinda kapanista calistirilacak + /// cumledir; engelleyici bulgularda kullanicinin elle uygulayacagi cumledir. Karsiligi olmayan + /// bulguda bostur. + /// + public string Statement { get; set; } + + /// + /// Bulgu bir tablo semasiyla ilgiliyse tablonun adi; istemci tablo tasarimcisini bu tablo icin + /// acar. Diger bulgularda bostur. + /// + public string TableName { get; set; } + + /// Tablo semasi; script'te sema oneki yoksa bostur. + public string SchemaName { get; set; } + + /// + /// hedefte hic yoksa true. Istemci tablo tasarimcisini bu bayrağa gore + /// acar: eksik tablo icin olusturma modunda (ad on dolu), var olan tablo icin duzenleme modunda. + /// + public bool TableMissing { get; set; } + + /// + /// Bulgu arsivin kendi icerigindeki bir eksiklikten ya da bozulmadan kaynaklaniyorsa + /// true: bozuk JSON, eksik bolum, karsiligi olmayan bir alt form / bagimlilik / script. + /// + /// Boyle bir bulgu hedef ortamda giderilemez — eksik olan veri arsivde yoktur, uydurulamaz. + /// Tek cozum kaynak ortamda export'u tekrarlamaktir; istemci bu bulgulari ayri baslikta ve + /// duzeltme dugmesi olmadan gosterir. + /// + /// + public bool RequiresReexport { get; set; } } /// Tek bir dosyanin hedefe yazilmasi istegi. @@ -77,7 +137,43 @@ public class WizardImportApplyResultDto /// public class WizardImportCompleteResultDto { + /// + /// Calistirilan SQL nesne scriptleri. Veri satirlari yazilmadan once uygulanir; tablo + /// yoksa once olusturulur, aksi halde veri adimi eksik tablo yuzunden bastan duserdi. + /// + public List AppliedSql { get; set; } = []; + + /// + /// Hedefte eksik olup eklenen kolonlar. Script'in CREATE blogu var olan bir tabloda + /// calismadigi icin yeni kolonlar buradan gelir; var olan bir kolon degistirilmez, hicbir kolon + /// dusurulmez — o tur farklar analiz adiminda import'u zaten engeller. + /// + public List AppliedSchema { get; set; } = []; + + /// Veritabanina uygulanan CRUD endpoint dosyalari. + public List AppliedCrud { get; set; } = []; + public List AppliedData { get; set; } = []; + + /// + /// Kurulan ekran tanimlari. Bagimlilik sirasinin son adimidir: ekranin tablosu, CRUD ucu ve + /// lookup verileri o noktada hazirdir. Hedefte zaten var olan bir ekran yeniden uretilmez ve + /// bunu Detail bildirir; import bitince ekran calisir durumdadir, migrate beklemez. + /// + public List AppliedWizards { get; set; } = []; +} + +/// Import kapanisindaki tek bir adimin sonucu (SQL scripti ya da CRUD dosyasi). +public class WizardImportStepResultDto +{ + /// Kapsam klasorune goreli yol. + public string Path { get; set; } + + /// Adim basariliysa yapilan isi ozetleyen kisa metin. + public string Detail { get; set; } + + /// Adim basarisizsa nedeni; basarili durumda bostur. + public string Error { get; set; } } /// Tek bir veri seed dosyasinin veritabanina uygulanma sonucu. diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index b6a5ee65..4315641a 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -26,6 +26,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.PermissionManagement; using Volo.Abp.Uow; using static Sozsoft.Platform.PlatformConsts; +using static Sozsoft.Settings.SettingsConsts; namespace Sozsoft.Platform.ListForms; @@ -54,6 +55,7 @@ public class ListFormWizardAppService( LanguageTextAppService languageTextAppService, IDynamicDataManager dynamicDataManager, IListFormSeedDataApplier seedDataApplier, + ICrudEndpointSeedApplier crudEndpointSeedApplier, ILogger logger ) : PlatformAppService(), IListFormWizardAppService { @@ -72,6 +74,7 @@ public class ListFormWizardAppService( private readonly LanguageTextAppService _languageTextAppService = languageTextAppService; private readonly IDynamicDataManager _dynamicDataManager = dynamicDataManager; private readonly IListFormSeedDataApplier _seedDataApplier = seedDataApplier; + private readonly ICrudEndpointSeedApplier _crudEndpointSeedApplier = crudEndpointSeedApplier; private readonly ILogger logger = logger; private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage; @@ -114,6 +117,31 @@ public class ListFormWizardAppService( ValidateWizardName(wizardName); await ValidateWizardUniquenessAsync(wizardName, code, input.MenuParentCode); + await DeployWizardAsync(input, null); + } + + /// + /// Wizard cevaplarindan ekranin tum kayitlarini uretir: dil anahtarlari, izin grubu ve + /// izinler, admin rolunun izin atamalari, modul ve menu, veri kaynagi, ListForm, alanlar ve + /// is akisi — ardindan seed dosyasini yazar. + /// + /// yalnizca import yolunda doludur ve ekranin zaten uretilmis + /// sozlesmesini tasir. Bu durumda ListForm ve alanlar wizard cevaplarindan yeniden + /// turetilmez; tasarimci uzerinde yapilmis her degisiklik (kolon genisligi, editor secenegi, + /// gorunum bloklari) oldugu gibi kurulur. Yeniden turetmek bu inceligi sessizce silerdi. + /// + /// + /// Cagiran, ad ve kod catismalarini bu metottan once reddetmis olmalidir; burada var + /// olan bir ekranin uzerine yazilip yazilmadigi denetlenmez. + /// + /// + private async Task DeployWizardAsync(ListFormWizardDto input, WizardSeedFileDto source) + { + var code = input.ResolveMenuCode(); + var listFormCode = input.ResolveListFormCode(); + input.ListFormCode = listFormCode; + input.MenuCode = code; + // Yol ayrımı: Custom seçildiğinde ListForm ekosistemi (veri kaynağı, ListForm, // alanlar, workflow) hiç üretilmez; menü doğrudan CustomComponent rotasına bağlanır. var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom; @@ -338,15 +366,31 @@ public class ListFormWizardAppService( await repoListFormWorkflow.DeleteManyAsync(existingWorkflowCriteria, autoSave: true); } - var tableColumns = await GetTableColumnNamesAsync(input.DataSourceCode, input.SelectCommandType, input.SelectCommand); - var isDeleted = tableColumns.Contains("IsDeleted"); - var isCreated = tableColumns.Contains("CreatorId"); + // Import yolunda ekranin sozlesmesi dosyada hazirdir ve oldugu gibi kurulur; wizard + // yolunda tek fabrikadan uretilir, boylece seed dosyasina yazilan da veritabanina yazilan + // da ayni nesne olur. Tablo kolonlari yalnizca uretim gerektiginde sorgulanir. + bool isDeleted; + bool isCreated; + GridOptionsEditDto listFormDto; + List fieldDtos; - // Ekranin sozlesmesi tek fabrikadan uretilir; seed dosyasina yazilan da veritabanina - // yazilan da ayni nesnedir. - var listFormDto = input.BuildListForm(isDeleted, isCreated); - var fieldDtos = input.BuildFields(); - var workflow = input.WorkflowDto; + if (source?.ListForm != null) + { + isDeleted = source.IsDeletedField; + isCreated = source.IsCreatedField; + listFormDto = source.ListForm; + fieldDtos = source.Fields ?? []; + } + else + { + var tableColumns = await GetTableColumnNamesAsync(input.DataSourceCode, input.SelectCommandType, input.SelectCommand); + isDeleted = tableColumns.Contains("IsDeleted"); + isCreated = tableColumns.Contains("CreatorId"); + listFormDto = input.BuildListForm(isDeleted, isCreated); + fieldDtos = input.BuildFields(); + } + + var workflow = listFormDto.WorkflowDto ?? input.WorkflowDto; var listForm = new ListForm(); listFormDto.ApplyTo(listForm); @@ -364,7 +408,7 @@ public class ListFormWizardAppService( await CreateLangKey(item.CaptionName, item.EnglishCaption, item.TurkishCaption, inserted); } - foreach (var criteria in workflow.Criteria) + foreach (var criteria in workflow.Criteria ?? []) { if (string.IsNullOrWhiteSpace(criteria.Id)) { @@ -810,72 +854,92 @@ public class ListFormWizardAppService( } /// - /// Wizard seed dosyasini ve bagli oldugu tum seed dosyalarini tek bir zip icinde dondurur. - /// Zip icindeki yollar seed kapsam klasoru (host / tenants/{tenantId}) koku baz alinarak yazilir; - /// dolayisiyla arsiv baska bir ortamda ayni klasor duzenine dogrudan acilabilir. + /// Wizard seed dosyasini ve tasinabilir olmasi icin gereken butun seed dosyalarini tek bir zip + /// icinde dondurur. Zip icindeki yollar seed kapsam klasoru (host / tenants/{tenantId}) koku baz + /// alinarak yazilir; dolayisiyla arsiv baska bir ortamda ayni klasor duzenine dogrudan acilabilir. /// - /// Custom yolunda: wizard/{dosya}.json, secili component ve bagimliliklariyla suzulmus - /// data/App.DeveloperKit.CustomComponents.json ve bu component'lerin data source'larinin - /// isaret ettigi crud/{entity}.json dosyalari. - /// List yolunda: wizard/{dosya}.json, SelectCommand'a karsilik gelen - /// {sql|postgres}/{object|execute}/{nesne}.sql ve varsa crud/{nesne}.json. + /// Bagimlilik kapanisi ozyinelemelidir (alt formlar, lookup nesneleri, ekran verisi, custom + /// component zinciri) — ayrinti icin . Cozulemeyen bir + /// bagimlilik indirmeyi engellemez; kullanici ile indirmeden once + /// eksikleri gorur. /// /// [Authorize(AppCodes.Listforms.Wizards.Export)] [HttpGet("api/app/list-form-wizard/export")] public async Task ExportFile(string fileName) { - var filePath = ResolveWizardSeedFilePath(fileName); - var json = await File.ReadAllTextAsync(filePath); - var seed = WizardSeedFileDto.FromSeedJson(json) - ?? throw new UserFriendlyException("Dosya okunamadı."); + var (items, warnings, archiveName) = await BuildExportPlanAsync(fileName); - var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id)); - - // Ayni dosyanin iki farkli yoldan (ornegin iki component ayni crud dosyasini kullanir) - // eklenmesini engellemek icin zip girdileri yola gore tekillestirilir. - var entries = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [$"{SeedPathResolver.WizardFolder}/{Path.GetFileName(filePath)}"] = filePath - }; - - // Paylasilan bir seed dosyasindan yalnizca ilgili satirlar suzuldugunde zip'e diskteki - // hali degil, uretilen icerik girer. - var generatedEntries = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (seed.Wizard?.ComponentKind == WizardComponentKindEnum.Custom) - await CollectCustomComponentEntriesAsync(scopeRoot, seed.Wizard.CustomComponentName, entries, generatedEntries); - else - CollectListComponentEntries(scopeRoot, seed.Wizard, entries); + foreach (var warning in warnings) + logger.LogWarning("Wizard export dependency missing for {FileName}: {Warning}", fileName, warning); var memory = new MemoryStream(); using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true)) { - foreach (var entry in entries.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) + foreach (var item in items) { - var zipEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal); - await using var source = File.OpenRead(entry.Value); + var zipEntry = archive.CreateEntry(item.Path, CompressionLevel.Optimal); await using var target = zipEntry.Open(); - await source.CopyToAsync(target); - } - foreach (var entry in generatedEntries.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)) - { - var zipEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal); - await using var target = zipEntry.Open(); - await target.WriteAsync(ImportEncoding.GetBytes(entry.Value)); + // Paylasilan bir seed dosyasindan yalnizca ilgili satirlar suzuldugunde zip'e + // diskteki hali degil, uretilen icerik girer. + if (item.Content != null) + { + await target.WriteAsync(ImportEncoding.GetBytes(item.Content)); + continue; + } + + await using var source = File.OpenRead(item.FullPath!); + await source.CopyToAsync(target); } } memory.Position = 0; + return new RemoteStreamContent(memory, archiveName, "application/zip"); + } + + /// + /// Indirmeden once arsivin tam icerigini ve cozulemeyen bagimliliklarini dondurur. Export ile + /// ayni toplamayi kullanir; onizlemedeki liste indirilen zip ile birebir aynidir. + /// + [Authorize(AppCodes.Listforms.Wizards.Export)] + [HttpGet("api/app/list-form-wizard/export-analysis")] + public async Task ExportAnalysis(string fileName) + { + var (items, warnings, archiveName) = await BuildExportPlanAsync(fileName); + + return new WizardExportAnalysisDto + { + FileName = fileName, + ArchiveName = archiveName, + ScopeFolder = SeedPathResolver.GetScopeFolderName(CurrentTenant.Id), + Entries = [.. items.Select(x => new WizardExportEntryDto + { + Path = x.Path, + Reason = x.Reason, + })], + Warnings = [.. warnings], + }; + } + + /// Export ve onizlemenin ortak toplama adimi; ikisi ayni arsivi tarif eder. + private async Task<(IReadOnlyList Items, IReadOnlyList Warnings, string ArchiveName)> BuildExportPlanAsync(string fileName) + { + var filePath = ResolveWizardSeedFilePath(fileName); + var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath)) + ?? throw new UserFriendlyException("Dosya okunamadı."); + + var collector = new WizardExportCollector(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id)); + var items = await collector.CollectAsync(filePath); + var safeName = string.Concat((seed.Wizard?.WizardName ?? Path.GetFileNameWithoutExtension(fileName)) .Trim() .Split(Path.GetInvalidFileNameChars())); if (string.IsNullOrWhiteSpace(safeName)) safeName = "Wizard"; - return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip"); + return (items, collector.Warnings, $"{safeName}.zip"); } // ── Import ───────────────────────────────────────────────────────────────────── @@ -891,14 +955,23 @@ public class ListFormWizardAppService( private const string ImportBackupFolder = "backup"; private const string ImportAppliedLogFile = "applied.log"; - /// Custom component'lerin seed kaynagi olan ListForm ekrani. - private const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents; - - /// Custom component satirlarinin anahtar alani; bagimliliklar bu adla eslesir. - private const string CustomComponentNameField = "Name"; + /// + /// Analizde engelleyici bulgu ciktiginda olusturulan isaret dosyasi. Yazma adimi bu dosyaya + /// bakar; boylece kontrol istemcinin karari degil sunucunun karari olur ve uc dogrudan + /// cagrilarak asilamaz. + /// + private const string ImportBlockedFile = "blocked"; private const long ImportMaxEntryBytes = 5 * 1024 * 1024; private const long ImportMaxArchiveBytes = 50 * 1024 * 1024; + + /// + /// Acilmis toplam icerigin ustsiniri. Zip girdisinin bildirdigi uzunluk sikistirilmis dosyanin + /// basligindan gelir ve dogrulanmis bir deger degildir; sinir bu yuzden okuma sirasinda da + /// uygulanir (zip bomb). + /// + private const long ImportMaxTotalUncompressedBytes = 200 * 1024 * 1024; + private const int ImportMaxEntryCount = 500; /// Kapsam klasorunde import edilmesine izin verilen kok klasorler. @@ -960,13 +1033,20 @@ public class ListFormWizardAppService( { ImportId = importId, ScopeFolder = SeedPathResolver.GetScopeFolderName(CurrentTenant.Id), + DataSourceCode = SeedConsts.DataSources.DefaultCode, }; + // Dogrulama, dosyalar hedefe yazilmadan once zip'in tamamini gormek zorundadir: bir veri + // dosyasinin ekrani ya da bir CRUD taniminin tablosu ayni zip'teki baska bir girdiyle + // karsilanmis olabilir. + var stagedEntries = new List(); + using (archive) { Directory.CreateDirectory(stagedRoot); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var totalUncompressed = 0L; foreach (var zipEntry in archive.Entries) { @@ -996,14 +1076,19 @@ public class ListFormWizardAppService( if (seen.Count > ImportMaxEntryCount) throw new UserFriendlyException("Zip içindeki dosya sayısı çok fazla."); - byte[] incoming; - await using (var source = zipEntry.Open()) - using (var entryBuffer = new MemoryStream()) + var incoming = await ReadZipEntryAsync(zipEntry, ImportMaxEntryBytes); + if (incoming == null) { - await source.CopyToAsync(entryBuffer); - incoming = entryBuffer.ToArray(); + result.Warnings.Add($"{relativePath}: dosya çok büyük, atlandı."); + continue; } + totalUncompressed += incoming.LongLength; + if (totalUncompressed > ImportMaxTotalUncompressedBytes) + throw new UserFriendlyException("Zip içeriği açıldığında izin verilen boyutu aşıyor."); + + stagedEntries.Add(new WizardImportStagedEntry(relativePath, incoming)); + var stagedPath = Path.Combine(stagedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!); await File.WriteAllBytesAsync(stagedPath, incoming); @@ -1073,10 +1158,211 @@ public class ListFormWizardAppService( throw new UserFriendlyException("Zip içinde import edilebilecek seed dosyası bulunamadı."); } + result.Issues = await ValidateImportAsync(stagedEntries, scopeRoot); + result.HasBlockingIssues = result.Issues.Exists(x => x.Severity == WizardImportIssueSeverityEnum.Error); + + // Karar sunucuda saklanir; yazma ucu dogrudan cagrilsa bile engelleyici bulgulu bir + // oturumda dosya yazamaz. + if (result.HasBlockingIssues) + await File.WriteAllTextAsync(Path.Combine(sessionPath, ImportBlockedFile), string.Empty); + result.Entries = [.. result.Entries.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)]; return result; } + /// + /// Zip girdisini ustsinira kadar okur; sinir asilirsa null doner. Girdinin bildirdigi uzunluk + /// dogrulanmis bir deger olmadigi icin sinir okuma sirasinda uygulanir. + /// + private static async Task ReadZipEntryAsync(ZipArchiveEntry zipEntry, long maxBytes) + { + await using var source = zipEntry.Open(); + using var buffer = new MemoryStream(); + + var chunk = new byte[81920]; + int read; + while ((read = await source.ReadAsync(chunk)) > 0) + { + if (buffer.Length + read > maxBytes) + return null; + + await buffer.WriteAsync(chunk.AsMemory(0, read)); + } + + return buffer.ToArray(); + } + + /// + /// Zip'in hedef ortamda yarim bir kurulum birakip birakmayacagini denetler. Eksik tablo + /// kontrolu icin "veritabaninda var" yetmez: zip nesneyi olusturan scripti tasiyorsa da + /// yeterlidir, cunku kapanis scriptleri veri satirlarindan once calistirir. + /// + private async Task> ValidateImportAsync( + IReadOnlyCollection stagedEntries, + string scopeRoot) + { + var validator = new WizardImportValidator( + scopeRoot, + SeedPathResolver.GetProviderFolderName(DefaultDatabaseProvider == DatabaseProvider.PostgreSql), + DatabaseObjectExistsAsync, + ReadTableColumnsAsync, + TableHasRowsAsync, + listFormCode => repoListForm.AnyAsync(a => a.ListFormCode == listFormCode)); + + return await validator.ValidateAsync(stagedEntries); + } + + /// + /// Tanimlayiciyi sorguya gomulmeye uygun hale getirir. + /// + /// Tablo adi parametre olarak baglanamaz (SQL tanimlayicilari parametrelesmez); ad seed + /// dosyasindan geldigi icin gomulmeden once denetlenir. Tirnak, koseli parantez ve noktali + /// virgul iceren bir ad reddedilir — boyle bir adin sorgudan cikmasi mumkun olmamalidir. + /// + /// + private static string QuoteIdentifier(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier) + || identifier.IndexOfAny(['"', '[', ']', ';', '\'', '\r', '\n']) >= 0) + { + throw new UserFriendlyException($"Geçersiz tablo adı: {identifier}"); + } + + return identifier; + } + + /// + /// Hedef tabloda kayit olup olmadigi; okunamazsa null doner ve cagiran risk almaz. + /// + /// Yalnizca varsayilani olmayan NOT NULL bir kolon eklenecegi zaman sorulur: bos tabloya + /// boyle bir kolon guvenle eklenir, dolu tabloya veritabani zaten izin vermez. + /// + /// + private async Task TableHasRowsAsync(string tableName) + { + try + { + var (repo, connectionString, _) = await _dynamicDataManager.GetAsync( + CurrentTenant.Id != null, SeedConsts.DataSources.DefaultCode); + + var count = await repo.ExecuteScalarAsync( + $"SELECT COUNT(1) FROM \"{QuoteIdentifier(tableName)}\"", connectionString); + + return count > 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Table row presence could not be checked: {TableName}", tableName); + return null; + } + } + + /// + /// Hedef tablonun kolonlarini katalogdan okur; tablo yoksa bos liste, okunamazsa null doner. + /// + /// Tip adi okunmaz ve karsilastirilmaz — saglayici katalogu kendi kanonik adini raporlar + /// (PostgreSQL'de VARCHAR icin character varying), script'teki yazilisla metin + /// olarak karsilastirmak sahte fark uretirdi. Yalnizca her iki saglayicida da ayni anlami + /// tasiyan olculer alinir. + /// + /// + private async Task?> ReadTableColumnsAsync(string tableName) + { + try + { + var (repo, connectionString, _) = await _dynamicDataManager.GetAsync( + CurrentTenant.Id != null, SeedConsts.DataSources.DefaultCode); + + // Tablo adi seed dosyasindan geldigi icin sorguya parametre olarak baglanir. + var rows = await repo.QueryAsync( + """ + SELECT COLUMN_NAME, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = @tableName + """, + connectionString, + new Dictionary { ["tableName"] = tableName }); + + var columns = new List(); + foreach (var row in rows) + { + if (row is not IDictionary values) + continue; + + var name = ReadColumnValue(values, "COLUMN_NAME")?.ToString(); + if (string.IsNullOrWhiteSpace(name)) + continue; + + columns.Add(new SqlLiveColumn( + name, + ToInt(ReadColumnValue(values, "CHARACTER_MAXIMUM_LENGTH")), + ToInt(ReadColumnValue(values, "NUMERIC_PRECISION")), + ToInt(ReadColumnValue(values, "NUMERIC_SCALE")), + string.Equals(ReadColumnValue(values, "IS_NULLABLE")?.ToString(), "YES", StringComparison.OrdinalIgnoreCase))); + } + + return columns; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Table schema could not be read for comparison: {TableName}", tableName); + return null; + } + } + + /// + /// Katalog kolonunu okur. Saglayicilar sutun adlarini farkli kasada dondurur (SQL Server + /// buyuk, PostgreSQL kucuk harf); bu yuzden ad kasadan bagimsiz aranir. + /// + private static object? ReadColumnValue(IDictionary row, string name) + { + if (row.TryGetValue(name, out var value)) + return value; + + foreach (var item in row) + { + if (string.Equals(item.Key, name, StringComparison.OrdinalIgnoreCase)) + return item.Value; + } + + return null; + } + + private static int? ToInt(object? value) + { + return value == null || value is DBNull + ? null + : Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture); + } + + /// + /// Nesnenin (tablo ya da gorunum) veri kaynaginda var olup olmadigi. Karar verilemiyorsa + /// (baglanti kurulamadi, izin yok) null doner; cagiran bunu hata degil uyari olarak isler. + /// + private async Task DatabaseObjectExistsAsync(string? dataSourceCode, string objectName) + { + var code = string.IsNullOrWhiteSpace(dataSourceCode) ? SeedConsts.DataSources.DefaultCode : dataSourceCode; + + try + { + // Kapsam tenant ise nesne tenant veritabaninda aranir; seed klasoru de o kapsamdadir. + var (repo, connectionString, _) = await _dynamicDataManager.GetAsync(CurrentTenant.Id != null, code); + + // Nesne adi kullanicidan geldigi icin sorguya parametre olarak baglanir. + var count = await repo.ExecuteScalarAsync( + "SELECT COUNT(1) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @objectName", + connectionString, + new Dictionary { ["objectName"] = objectName }); + + return count > 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Database object existence could not be checked: {DataSourceCode}/{ObjectName}", code, objectName); + return null; + } + } + [Authorize(AppCodes.Listforms.Wizards.Import)] [HttpPost("api/app/list-form-wizard/apply-import")] public async Task ApplyImport(WizardImportApplyInput input) @@ -1084,6 +1370,9 @@ public class ListFormWizardAppService( Check.NotNull(input, nameof(input)); var sessionPath = GetExistingImportSessionPath(input.ImportId); + + EnsureImportNotBlocked(sessionPath); + var relativePath = NormalizeImportEntryPath(input.Path) ?? throw new UserFriendlyException("Geçersiz dosya yolu."); @@ -1138,18 +1427,27 @@ public class ListFormWizardAppService( } /// - /// Import oturumunu kapatir. Kapanistan once zip'in getirdigi data/*.json dosyalari - /// veritabanina uygulanir: tabloda olmayan satirlar eklenir, var olanlar dosyadaki degerlerle - /// guncellenir ve soft delete edilmis satirlarin silinme izleri temizlenir — aksi halde kayit - /// tabloda durur ama ekranin varsayilan suzgeci onu gizlemeye devam ederdi. Hedefle zaten ayni - /// olan dosyalar da uygulanir: yazacak bir sey yoktur ama satirlar veritabaninda eksik ya da - /// silinmis olabilir. Migrate/seed akisindan farki budur — orada dosya bayat olabilir ve canli kayit - /// ezilmez; import ise kullanicinin bilincli getirdigi icerigi tasir. Diger klasorler - /// (wizard, crud, sql) dosya olarak kalir; onlar seeder ya da wizard deploy ile veritabanina gecer. + /// Import oturumunu kapatir ve zip'in getirdigini veritabanina uygular. Sira bagimlilik + /// sirasidir ve bilincli olarak sabittir: + /// + /// SQL nesne scriptleri — tablo yoksa once olusturulur; aksi halde sonraki iki adim + /// eksik tablo yuzunden bastan duserdi. + /// CRUD endpoint tanimlari — tablolar hazir olduktan sonra yazilir. + /// Veri satirlari — tabloda olmayan satir eklenir, var olan dosyadaki degerlerle + /// guncellenir ve soft delete izleri temizlenir; aksi halde kayit tabloda durur ama ekranin + /// varsayilan suzgeci onu gizlemeye devam ederdi. + /// + /// Hedefle zaten ayni olan dosyalar da uygulanir: yazacak bir sey yoktur ama veritabaninda + /// karsiligi eksik ya da silinmis olabilir. Migrate/seed akisindan farki budur — orada dosya + /// bayat olabilir ve canli kayit ezilmez; import ise kullanicinin bilincli getirdigi icerigi tasir. /// - /// Uygulama basarisiz olsa bile oturum kapatilir; dosyalar hedefe yazilmis durumdadir ve - /// hata sonuca tasinir. Bu adim geri alinamaz, cunku dosya yedegi veritabani kaydini - /// kapsamaz — geri alma RollbackImport ile bu adimdan once yapilir. + /// Ekran tanimlari (wizard/) bu adimda uygulanmaz; onlari bir sonraki migrate/seed + /// calismasinda WizardDataSeeder kurar ve sonucta bekleyen ekran olarak raporlanir. + /// + /// + /// Uygulama basarisiz olsa bile oturum kapatilir; dosyalar hedefe yazilmis durumdadir ve hata + /// sonuca tasinir. Bu adim geri alinamaz, cunku dosya yedegi veritabani kaydini kapsamaz — + /// geri alma RollbackImport ile bu adimdan once yapilir. /// /// [Authorize(AppCodes.Listforms.Wizards.Import)] @@ -1157,41 +1455,287 @@ public class ListFormWizardAppService( public async Task CompleteImport(string importId) { var sessionPath = GetExistingImportSessionPath(importId); + + // Kapanis, yazilmis dosyalarin yani sira hedefle zaten ayni olan staging girdilerini de + // uygular; dolayisiyla hicbir dosya yazilmamis olsa bile veritabanina dokunur. Engelleme + // burada da denetlenmezse bu uc dogrudan cagrilarak analiz karari atlanabilirdi. + EnsureImportNotBlocked(sessionPath); + var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id)); var result = new WizardImportCompleteResultDto(); - var dataFiles = await ReadAppliedDataFilesAsync(sessionPath, scopeRoot); - if (dataFiles.Count > 0) + var providerFolder = SeedPathResolver.GetProviderFolderName(DefaultDatabaseProvider == DatabaseProvider.PostgreSql); + + // 1) Nesne scriptleri. object once, execute sonra: execute scriptleri nesneyi olusturmanin + // yaninda calistirilmak uzere yazilmistir ve object'te kurulan tabloya dayanabilir. + var sqlFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, $"{providerFolder}/", ".sql"); + foreach (var relativePath in sqlFiles.OrderBy(x => x.Contains($"/{SeedPathResolver.ObjectFolder}/", StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(x => x, StringComparer.OrdinalIgnoreCase)) { - foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true)) - { - result.AppliedData.Add(new WizardImportSeedApplyResultDto - { - Path = applied.Path, - InsertedRowCount = applied.Inserted, - UpdatedRowCount = applied.Updated, - SkippedRowCount = applied.Skipped, - Error = applied.Error, - }); - } + result.AppliedSql.Add(await ExecuteSeedScriptAsync(scopeRoot, relativePath)); } + // 2) Eksik kolonlar. Script'in CREATE blogu var olan bir tabloda calismadigi icin yeni + // kolonlar buradan gelir; olcusu degismis bir kolon analiz adiminda zaten import'u + // engellemistir, dolayisiyla burada yalnizca kayipsiz ekleme kalir. + foreach (var relativePath in sqlFiles) + result.AppliedSchema.AddRange(await ApplyMissingColumnsAsync(scopeRoot, relativePath)); + + // 3) CRUD endpoint tanimlari. + var crudFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.CrudFolder + "/", ".json"); + foreach (var applied in await _crudEndpointSeedApplier.ApplyFilesAsync(CurrentTenant.Id, crudFiles)) + { + result.AppliedCrud.Add(new WizardImportStepResultDto + { + Path = applied.Path, + Detail = $"{applied.Inserted} eklendi, {applied.Updated} güncellendi, {applied.Deleted} silindi, {applied.Skipped} korundu.", + Error = applied.Error, + }); + } + + // 4) Veri satirlari. + var dataFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.DataFolder + "/", ".json"); + foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true)) + { + result.AppliedData.Add(new WizardImportSeedApplyResultDto + { + Path = applied.Path, + InsertedRowCount = applied.Inserted, + UpdatedRowCount = applied.Updated, + SkippedRowCount = applied.Skipped, + Error = applied.Error, + }); + } + + // 5) Ekran tanimlari. En sonda calisir: ListForm'un dayandigi tablo, CRUD ucu ve lookup + // verileri bu noktada hazirdir. Zaten kurulu bir ekran yeniden uretilmez — tekrar kurmak + // tasarimci uzerinde yapilmis degisiklikleri silerdi. + var wizardFiles = await ReadEffectiveFilesAsync(sessionPath, scopeRoot, SeedPathResolver.WizardFolder + "/", ".json"); + foreach (var relativePath in wizardFiles) + result.AppliedWizards.Add(await ApplyImportedWizardAsync(scopeRoot, relativePath)); + + if (result.AppliedWizards.Exists(x => x.Error == null)) + await _languageTextAppService.ClearRedisCacheAsync(); + DeleteImportSession(sessionPath); return result; } /// - /// Veritabanina uygulanacak data/ girdilerini toplar. + /// Import edilen bir wizard dosyasindaki ekrani kurar. /// - /// Once oturumda gercekten yazilmis dosyalar (kayit dosyasindan) alinir. Ardindan yazilmamis - /// staging girdilerinden hedefle ayni olanlar eklenir: dosya ayni oldugu icin yazacak - /// bir sey yoktu, ama satirlar veritabaninda eksik ya da soft delete edilmis olabilir. Hedeften - /// farkli olup yazilmamis dosya kullanicinin "mevcudu koru" karari demektir; ona dokunulmaz. + /// Kurulum wizard ekraninin kullandigi DeployWizardAsync ile ayni yoldan gecer; ikinci + /// bir uretim kopyasi yoktur. Dosya ekranin uretilmis sozlesmesini tasidigi icin ListForm ve + /// alanlar yeniden turetilmez, oldugu gibi kurulur. + /// + /// + /// Ekran hedefte zaten varsa dokunulmaz: import var olan bir ekrani ezmez, yeniden + /// uretmek tasarimci uzerindeki degisiklikleri silerdi. Custom yolunda ListForm uretilmedigi + /// icin ayni karar menu (menusuz wizard'da okuma izni) uzerinden verilir. /// /// - private async Task> ReadAppliedDataFilesAsync(string sessionPath, string scopeRoot) + private async Task ApplyImportedWizardAsync(string scopeRoot, string relativePath) + { + var step = new WizardImportStepResultDto { Path = relativePath }; + + try + { + var fullPath = ResolveScopeFilePath(scopeRoot, relativePath); + if (fullPath == null) + { + step.Error = "Dosya kapsam klasöründe bulunamadı."; + return step; + } + + var seed = WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(fullPath)); + if (seed?.Wizard == null || string.IsNullOrWhiteSpace(seed.Wizard.WizardName)) + { + step.Error = "Geçerli bir wizard dosyası değil."; + return step; + } + + var input = seed.Wizard; + var code = input.ResolveMenuCode(); + + if (input.ComponentKind == WizardComponentKindEnum.Custom) + { + if (seed.ListForm != null) + { + step.Error = "Custom wizard dosyasında ListForm bölümü var; dosya tutarsız."; + return step; + } + } + else if (seed.ListForm == null) + { + step.Error = "ListForm bölümü eksik, ekran kurulamaz."; + return step; + } + + var alreadyInstalled = input.ComponentKind == WizardComponentKindEnum.Custom + ? input.CreateMenu + ? await repoMenu.AnyAsync(a => a.Code == code) + : await repoPerm.AnyAsync(a => a.Name == code) + : await repoListForm.AnyAsync(a => a.ListFormCode == input.ResolveListFormCode()); + + if (alreadyInstalled) + { + step.Detail = $"'{input.WizardName}' zaten kurulu; dokunulmadı."; + return step; + } + + // Her ekran kendi isleminde kurulur: bir dosyadaki hata yalnizca o ekranin + // kayitlarini geri alir, once kurulmus ekranlari ve import'un geri kalanini bozmaz. + // Ortak islemde birakilsaydi yarim kurulmus bir ekran kapanista yine de yazilirdi. + using var uow = UnitOfWorkManager.Begin(new AbpUnitOfWorkOptions(), requiresNew: true); + await DeployWizardAsync(input, seed); + await uow.CompleteAsync(); + + step.Detail = $"'{input.WizardName}' ekranı kuruldu."; + } + catch (Exception ex) + { + step.Error = ex.Message; + } + + return step; + } + + /// + /// Script'in kurdugu tabloda eksik olan kolonlari ekler. + /// + /// Uretilen scriptler IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END kalibiyla + /// sarilidir; hedefte tablo varsa CREATE blogu atlanir ve yeni bir kolon script'in + /// kendisiyle asla gelmez. Ekleme kayipsiz oldugu icin otomatik yapilir. Var olan bir kolonun + /// olcusunu degistirmez ve hicbir kolonu dusurmez — o tur farklar analiz adiminda + /// import'u zaten engellemistir. + /// + /// + private async Task> ApplyMissingColumnsAsync(string scopeRoot, string relativePath) + { + var results = new List(); + + var fullPath = ResolveScopeFilePath(scopeRoot, relativePath); + if (fullPath == null) + return results; + + var comparison = await SqlTableSchemaComparer.CompareAsync( + await File.ReadAllTextAsync(fullPath), ReadTableColumnsAsync, TableHasRowsAsync); + + if (comparison == null) + return results; + + if (comparison.MissingColumns.Count == 0) + return results; + + var (repo, connectionString, _) = await _dynamicDataManager.GetAsync( + CurrentTenant.Id != null, SeedConsts.DataSources.DefaultCode); + + foreach (var column in comparison.MissingColumns) + { + var step = new WizardImportStepResultDto + { + Path = relativePath, + Detail = $"{comparison.TableName}.{column.ColumnName} eklendi.", + }; + + if (column.BlockingReason != null) + { + // Analiz bunu hata olarak isaretledigi icin normalde buraya gelinmez; oturum + // arasinda sema degismis olabilir, sessizce zorlanmaz. + step.Detail = null; + step.Error = $"{comparison.TableName}.{column.ColumnName} eklenmedi — {column.BlockingReason}."; + results.Add(step); + continue; + } + + try + { + await repo.ExecuteAsync(column.Statement, connectionString); + logger.LogInformation("Wizard import added column: {Statement}", column.Statement); + } + catch (Exception ex) + { + step.Detail = null; + step.Error = $"{comparison.TableName}.{column.ColumnName} eklenemedi: {ex.Message}"; + logger.LogError(ex, "Wizard import could not add column: {Statement}", column.Statement); + } + + results.Add(step); + } + + return results; + } + + /// + /// Analizde engelleyici bulgu cikmis bir oturumda islemi reddeder. Karar sunucuda saklandigi + /// icin istemci atlayamaz; veritabanina dokunan her uc bunu cagirir. + /// + private static void EnsureImportNotBlocked(string sessionPath) + { + if (File.Exists(Path.Combine(sessionPath, ImportBlockedFile))) + throw new UserFriendlyException("Import, giderilmemiş hatalar nedeniyle sürdürülemez."); + } + + /// + /// Kapsam klasorundeki bir seed scriptini calistirir. Script'ler varlik kontrollu yazilir + /// (nesne varsa olusturma atlanir), bu yuzden yeniden calistirilmalari guvenlidir. Hata tek + /// script ile sinirli kalir; kalan adimlar yurumeye devam eder ve sonuc kullaniciya raporlanir. + /// + private async Task ExecuteSeedScriptAsync(string scopeRoot, string relativePath) + { + var step = new WizardImportStepResultDto { Path = relativePath }; + + try + { + var fullPath = ResolveScopeFilePath(scopeRoot, relativePath) + ?? throw new UserFriendlyException($"Script bulunamadı: {relativePath}"); + + var (repo, connectionString, _) = await _dynamicDataManager.GetAsync( + CurrentTenant.Id != null, SeedConsts.DataSources.DefaultCode); + + // SqlDataSeeder ile ayni bolme kurali: GO satirlari batch ayracidir. + var batches = Regex.Split(await File.ReadAllTextAsync(fullPath), @"^\s*GO\s*$", + RegexOptions.Multiline | RegexOptions.IgnoreCase); + + var executed = 0; + foreach (var batch in batches) + { + var sql = batch.Trim(); + if (string.IsNullOrWhiteSpace(sql)) + continue; + + await repo.ExecuteAsync(sql, connectionString); + executed++; + } + + step.Detail = $"{executed} batch çalıştırıldı."; + logger.LogInformation("Wizard import script executed: {RelativePath} ({Count} batch)", relativePath, executed); + } + catch (Exception ex) + { + step.Error = ex.Message; + logger.LogError(ex, "Wizard import script failed: {RelativePath}", relativePath); + } + + return step; + } + + /// + /// Bir kok klasordeki, veritabanina uygulanmasi gereken girdileri kapsam-goreli yollariyla + /// dondurur. + /// + /// Once oturumda gercekten yazilmis dosyalar (kayit dosyasindan) alinir. Ardindan yazilmamis + /// staging girdilerinden hedefle ayni olanlar eklenir: dosya ayni oldugu icin yazacak bir + /// sey yoktu, ama veritabanindaki karsiligi eksik ya da silinmis olabilir. Hedeften farkli olup + /// yazilmamis dosya kullanicinin "mevcudu koru" karari demektir; ona dokunulmaz. + /// + /// + private async Task> ReadEffectiveFilesAsync( + string sessionPath, + string scopeRoot, + string folderPrefix, + string extension) { - var prefix = SeedPathResolver.DataFolder + "/"; var paths = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -1201,7 +1745,7 @@ public class ListFormWizardAppService( foreach (var line in await File.ReadAllLinesAsync(logPath)) { var parts = line.Split('|', 2); - if (parts.Length != 2 || !parts[1].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + if (parts.Length != 2 || !parts[1].StartsWith(folderPrefix, StringComparison.OrdinalIgnoreCase)) continue; if (seen.Add(parts[1])) @@ -1209,13 +1753,15 @@ public class ListFormWizardAppService( } } - var stagedDataPath = Path.Combine(sessionPath, ImportStagedFolder, SeedPathResolver.DataFolder); - if (!Directory.Exists(stagedDataPath)) + var stagedFolder = Path.Combine( + sessionPath, ImportStagedFolder, folderPrefix.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar)); + + if (!Directory.Exists(stagedFolder)) return paths; - foreach (var stagedFile in Directory.GetFiles(stagedDataPath, "*.json")) + foreach (var stagedFile in Directory.GetFiles(stagedFolder, "*" + extension, SearchOption.AllDirectories)) { - var relativePath = prefix + Path.GetFileName(stagedFile); + var relativePath = folderPrefix + Path.GetRelativePath(stagedFolder, stagedFile).Replace('\\', '/'); if (seen.Contains(relativePath)) continue; @@ -1227,7 +1773,7 @@ public class ListFormWizardAppService( var existing = await File.ReadAllBytesAsync(targetPath); // Karsilastirma analiz adimiyla ayni olmali: veri dosyalarinda satir bazlidir. - var scope = BuildSeedDataScope(staged, existing); + var scope = IsSeedDataPath(relativePath) ? BuildSeedDataScope(staged, existing) : null; var isIdentical = scope != null ? string.Equals(scope.Incoming, scope.Existing, StringComparison.Ordinal) : string.Equals(NormalizeImportText(staged), NormalizeImportText(existing), StringComparison.Ordinal); @@ -1335,7 +1881,7 @@ public class ListFormWizardAppService( var existingByKey = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var row in existingFile.Rows) { - var key = GetRowText(row, keyFieldName); + var key = WizardExportCollector.GetRowText(row, keyFieldName); if (!string.IsNullOrWhiteSpace(key)) existingByKey[key] = row; } @@ -1344,7 +1890,7 @@ public class ListFormWizardAppService( var matched = new List>(); foreach (var row in incomingFile.Rows) { - var key = GetRowText(row, keyFieldName); + var key = WizardExportCollector.GetRowText(row, keyFieldName); if (!string.IsNullOrWhiteSpace(key) && existingByKey.TryGetValue(key, out var match)) matched.Add(match); } @@ -1403,14 +1949,14 @@ public class ListFormWizardAppService( var indexByKey = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < merged.Count; i++) { - var key = GetRowText(merged[i], keyFieldName); + var key = WizardExportCollector.GetRowText(merged[i], keyFieldName); if (!string.IsNullOrWhiteSpace(key)) indexByKey[key] = i; } foreach (var row in incomingFile.Rows) { - var key = GetRowText(row, keyFieldName); + var key = WizardExportCollector.GetRowText(row, keyFieldName); if (string.IsNullOrWhiteSpace(key)) continue; @@ -1548,187 +2094,6 @@ public class ListFormWizardAppService( } } - /// - /// Custom component'lerin seed kaynagi data/App.DeveloperKit.CustomComponents.json - /// dosyasidir. Dosya kapsamdaki butun component'leri tasidigi icin zip'e oldugu gibi degil, - /// secili component ile bagimlilik zincirine suzulmus bir kopyasi konur; boylece arsiv ilgisiz - /// component'leri disari tasimaz. Ayni zincirdeki component'lerin data source'larinin isaret - /// ettigi crud seed dosyalari da toplanir. Bagimlilik zinciri dongu icerse bile ziyaret edilen - /// adlar takip edildigi icin sonsuz dongu olusmaz. - /// - private async Task CollectCustomComponentEntriesAsync( - string scopeRoot, - string? componentName, - Dictionary entries, - Dictionary generatedEntries) - { - if (string.IsNullOrWhiteSpace(componentName)) - return; - - var relativePath = SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode); - var sourcePath = ResolveScopeFilePath(scopeRoot, relativePath); - if (sourcePath == null) - { - logger.LogWarning("Custom component seed data file not found for export: {RelativePath}", relativePath); - return; - } - - ListFormSeedDataFileDto? seedFile; - try - { - seedFile = JsonSerializer.Deserialize( - await File.ReadAllTextAsync(sourcePath), SeedJsonReadOptions); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Custom component seed data file could not be parsed for export: {RelativePath}", relativePath); - return; - } - - if (seedFile == null) - return; - - var rowsByName = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var row in seedFile.Rows) - { - var name = GetRowText(row, CustomComponentNameField); - if (!string.IsNullOrWhiteSpace(name)) - rowsByName[name] = row; - } - - var pending = new Queue(); - var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - var exported = new HashSet(StringComparer.OrdinalIgnoreCase); - pending.Enqueue(componentName.Trim()); - - while (pending.Count > 0) - { - var name = pending.Dequeue(); - if (!visited.Add(name) || !rowsByName.TryGetValue(name, out var row)) - continue; - - exported.Add(name); - - foreach (var dependency in ParseSeedRowList(GetRowText(row, "Dependencies"))) - { - if (!string.IsNullOrWhiteSpace(dependency)) - pending.Enqueue(dependency.Trim()); - } - - foreach (var dataSource in ParseSeedRowList(GetRowText(row, "DataSources"))) - { - // SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur; - // elle yazilmis bir URL'in seed karsiligi yoktur. - if (string.IsNullOrWhiteSpace(dataSource.SeedFile)) - continue; - - if (!TryAddSeedEntry(scopeRoot, dataSource.SeedFile, entries)) - logger.LogWarning("CRUD seed file not found for export: {RelativePath}", dataSource.SeedFile); - } - } - - if (exported.Count == 0) - { - logger.LogWarning("Custom component not found in the seed data file for export: {ComponentName}", componentName); - return; - } - - // Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla birlestirilir. - seedFile.Rows = [.. seedFile.Rows.Where(x => exported.Contains(GetRowText(x, CustomComponentNameField) ?? string.Empty))]; - generatedEntries[relativePath] = JsonSerializer.Serialize(seedFile, ListFormSeedDataFileDto.JsonOptions); - } - - /// Seed satirindaki bir alani metin olarak okur; JsonElement ve duz deger destekler. - private static string? GetRowText(Dictionary row, string fieldName) - { - if (!row.TryGetValue(fieldName, out var value) || value is null) - return null; - - return value is JsonElement element - ? (element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString()) - : value.ToString(); - } - - /// Seed satirinda JSON dizisi olarak tutulan alanlari cozer; bozuk deger bos liste doner. - private static List ParseSeedRowList(string? json) - { - if (string.IsNullOrWhiteSpace(json)) - return []; - - try - { - return JsonSerializer.Deserialize>(json, SeedJsonReadOptions) ?? []; - } - catch (JsonException) - { - return []; - } - } - - /// - /// List yolunda wizard'in SelectCommand'ina karsilik gelen .sql dosyasini ve ayni nesne adiyla - /// uretilmis crud seed dosyasini toplar. Query tipinde SelectCommand bir nesne adi degil ham SQL - /// oldugu icin dosya aramasi yapilmaz. - /// - private void CollectListComponentEntries(string scopeRoot, ListFormWizardDto? wizard, Dictionary entries) - { - if (wizard == null || wizard.SelectCommandType == SelectCommandTypeEnum.Query) - return; - - var objectName = NormalizeDatabaseObjectName(wizard.SelectCommand); - if (objectName == null) - return; - - // Saglayici (sql / postgres) ve klasor turu (object / execute) wizard dosyasinda tutulmadigi - // icin bilinen dort kombinasyon sirayla denenir; hangisi varsa arsive eklenir. - string[] providerFolders = [SeedPathResolver.SqlFolder, SeedPathResolver.PostgresFolder]; - string[] scriptFolders = [SeedPathResolver.ObjectFolder, SeedPathResolver.ExecuteFolder]; - - var sqlFound = false; - foreach (var provider in providerFolders) - { - foreach (var scriptFolder in scriptFolders) - { - if (TryAddSeedEntry(scopeRoot, $"{provider}/{scriptFolder}/{objectName}.sql", entries)) - sqlFound = true; - } - } - - if (!sqlFound) - logger.LogWarning("SQL seed file not found for export: {ObjectName}", objectName); - - TryAddSeedEntry(scopeRoot, $"{SeedPathResolver.CrudFolder}/{objectName}.json", entries); - } - - /// Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur. - private static string? NormalizeDatabaseObjectName(string? selectCommand) - { - var trimmed = selectCommand?.Trim(); - if (string.IsNullOrWhiteSpace(trimmed)) - return null; - - var name = trimmed.Split('.')[^1].Trim('"', '[', ']', ' '); - if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) - return null; - - return name; - } - - /// - /// Kapsam klasorune goreli bir seed dosyasini arsiv listesine ekler. Yol her zaman kapsam - /// klasoru icinde kalmali; disari cikan bir yol (seed dosyasindaki bozuk bir kayittan gelebilir) - /// sessizce reddedilir. Dosya yoksa false doner. - /// - private static bool TryAddSeedEntry(string scopeRoot, string relativePath, Dictionary entries) - { - var fullPath = ResolveScopeFilePath(scopeRoot, relativePath); - if (fullPath == null) - return false; - - entries[relativePath.Replace('\\', '/').Trim('/')] = fullPath; - return true; - } - /// /// Kapsam klasorune goreli yolu var olan bir dosyanin tam yoluna cevirir; kapsam disina cikan /// ya da bulunmayan bir yol icin null doner. diff --git a/api/src/Sozsoft.Platform.Application/ListForms/SqlCreateTableParser.cs b/api/src/Sozsoft.Platform.Application/ListForms/SqlCreateTableParser.cs new file mode 100644 index 00000000..233d3d14 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/ListForms/SqlCreateTableParser.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Bir seed script'inde bildirilen kolon. script'teki ham metindir; hazir +/// ALTER TABLE ADD cumlesi bundan uretilir, boylece DEFAULT ve COLLATE gibi ekler korunur. +/// +/// Tip adi tasinmaz: karsilastirmaya girmez (bkz. ), yalnizca +/// parantezli olculeri okunur. +/// +/// +public sealed record SqlColumnDeclaration( + string Name, + string Definition, + int? Length, + int? Precision, + int? Scale, + bool? IsNullable); + +/// +/// Bir seed script'inden okunan tablo tanimi. script'teki yaziliştir +/// (sema oneki ve tirnaklar dahil); uretilen ALTER cumlesi onu oldugu gibi kullanir. +/// script'te sema oneki yoksa bostur. +/// +public sealed record SqlTableDeclaration( + string TableName, + string? SchemaName, + string RawName, + IReadOnlyList Columns); + +/// +/// Seed script'indeki CREATE TABLE bloklarindan kolon tanimlarini okur. +/// +/// Amac tam bir SQL ayristiricisi olmak degil, hedef tablonun canli semasiyla karsilastirilabilecek +/// kadarini guvenle cikarmaktir. Cozulemeyen her sey sessizce atlanir: bilinmeyen bir sozdizimi +/// icin tahmin yurutulmez, cunku yanlis bir "fark" raporu import'u haksiz yere engeller. +/// +/// +public static class SqlCreateTableParser +{ + /// Kolon degil, tablo duzeyinde kisit tanimlayan girdiler; sema karsilastirmasina girmezler. + private static readonly Regex TableConstraintPattern = new( + @"^\s*(CONSTRAINT|PRIMARY\s+KEY|UNIQUE|FOREIGN\s+KEY|CHECK|INDEX|KEY|EXCLUDE|PERIOD)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex CreateTablePattern = new( + @"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?(?:\[[^\]]+\]|""[^""]+""|[\w$]+)(?:\s*\.\s*(?:\[[^\]]+\]|""[^""]+""|[\w$]+))*)\s*\(", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Kolon adi ve ardindan gelen tanim. + private static readonly Regex ColumnPattern = new( + @"^\s*(?\[[^\]]+\]|""[^""]+""|[\w$]+)\s+(?.+)$", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// + /// Tip metninden sonra gelebilecek kolon nitelikleri. Tip cok kelimeli olabildigi icin + /// (DOUBLE PRECISION, CHARACTER VARYING, TIMESTAMP WITH TIME ZONE) tip, + /// kelime sayisiyla degil bu anahtar kelimelerden biriyle sonlandirilir. Liste SQL dilbilgisidir, + /// veriye dair bir varsayim degildir. + /// + private static readonly HashSet TypeTerminators = new(StringComparer.OrdinalIgnoreCase) + { + "NOT", "NULL", "CONSTRAINT", "DEFAULT", "IDENTITY", "PRIMARY", "UNIQUE", "CHECK", + "REFERENCES", "COLLATE", "GENERATED", "COMMENT", "SPARSE", "ROWGUIDCOL", "MASKED", + "FILESTREAM", "AS", "ENCRYPTED", "SERIAL", + }; + + /// Tip metnindeki uzunluk/kesinlik argumanlari. + private static readonly Regex TypeArgumentPattern = new( + @"\(\s*(?MAX|\d+)\s*(?:,\s*(?\d+)\s*)?\)\s*$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Script'teki ilk CREATE TABLE blogunu okur. Blok bulunamaz ya da cozulemezse null doner + /// ve cagiran sema karsilastirmasini atlar. + /// + public static SqlTableDeclaration? Parse(string? sql) + { + if (string.IsNullOrWhiteSpace(sql)) + return null; + + var statement = RemoveComments(sql); + var match = CreateTablePattern.Match(statement); + if (!match.Success) + return null; + + var body = ReadBalancedBody(statement, match.Index + match.Length - 1); + if (body == null) + return null; + + var columns = new List(); + foreach (var part in SplitTopLevel(body)) + { + var definition = part.Trim().TrimEnd(','); + if (definition.Length == 0 || TableConstraintPattern.IsMatch(definition)) + continue; + + var column = ParseColumn(definition); + if (column != null) + columns.Add(column); + } + + return columns.Count == 0 + ? null + : BuildDeclaration(match.Groups["name"].Value, columns); + } + + /// Nitelenmis tablo adini sema ve tablo parcalarina ayirir. + private static SqlTableDeclaration BuildDeclaration(string rawName, IReadOnlyList columns) + { + var segments = rawName.Split('.'); + + return new SqlTableDeclaration( + Unquote(segments[^1]), + segments.Length > 1 ? Unquote(segments[^2]) : null, + rawName.Trim(), + columns); + } + + private static SqlColumnDeclaration? ParseColumn(string definition) + { + var match = ColumnPattern.Match(definition); + if (!match.Success) + return null; + + var name = Unquote(match.Groups["name"].Value); + var rest = match.Groups["definition"].Value.Trim(); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var typeText = ReadTypeText(rest); + int? length = null; + int? precision = null; + int? scale = null; + + if (typeText != null) + { + var argumentMatch = TypeArgumentPattern.Match(typeText); + if (argumentMatch.Success) + { + // NVARCHAR(MAX) INFORMATION_SCHEMA'da -1 uzunluk olarak gorunur. + var first = argumentMatch.Groups["a1"].Value.Equals("MAX", StringComparison.OrdinalIgnoreCase) + ? -1 + : int.Parse(argumentMatch.Groups["a1"].Value, CultureInfo.InvariantCulture); + + var second = argumentMatch.Groups["a2"].Value; + if (second.Length > 0) + { + precision = first; + scale = int.Parse(second, CultureInfo.InvariantCulture); + } + else + { + length = first; + } + } + } + + // "NOT NULL" acik; yalniz "NULL" da acik. Ikisi de yoksa saglayicinin varsayilani gecerlidir + // ve bir sey iddia edilmez. + bool? isNullable = Regex.IsMatch(rest, @"\bNOT\s+NULL\b", RegexOptions.IgnoreCase) + ? false + : Regex.IsMatch(rest, @"(? + /// Kolon taniminin basindaki tip metnini dondurur: ilk nitelik anahtar kelimesine kadar olan + /// kisim, parantezli argumanlari dahil. Okunamazsa null doner ve olcu karsilastirmasi atlanir. + /// + private static string? ReadTypeText(string definition) + { + var end = definition.Length; + var wordStart = -1; + var depth = 0; + + for (var i = 0; i <= definition.Length; i++) + { + var c = i < definition.Length ? definition[i] : ' '; + + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + } + + var isWordChar = depth == 0 && (char.IsLetterOrDigit(c) || c == '_' || c == '$'); + + if (isWordChar) + { + if (wordStart < 0) + wordStart = i; + + continue; + } + + if (wordStart < 0) + continue; + + // Tip adinin ilk kelimesi terminator olamaz; sonrakiler tipi sonlandirir. + if (wordStart > 0 && TypeTerminators.Contains(definition[wordStart..i])) + { + end = wordStart; + break; + } + + wordStart = -1; + } + + var text = definition[..end].Trim(); + + return text.Length == 0 ? null : text; + } + + /// Acilis parantezinden baslayarak esleyen kapanisa kadar olan govdeyi dondurur. + private static string? ReadBalancedBody(string sql, int openIndex) + { + var depth = 0; + + for (var i = openIndex; i < sql.Length; i++) + { + var c = sql[i]; + + if (c == '\'' || c == '"' || c == '[') + { + i = SkipQuoted(sql, i); + continue; + } + + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth == 0) + return sql[(openIndex + 1)..i]; + } + } + + return null; + } + + /// Govdeyi yalnizca en dis seviyedeki virgullerden boler. + private static IEnumerable SplitTopLevel(string body) + { + var depth = 0; + var start = 0; + + for (var i = 0; i < body.Length; i++) + { + var c = body[i]; + + if (c == '\'' || c == '"' || c == '[') + { + i = SkipQuoted(body, i); + continue; + } + + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + } + else if (c == ',' && depth == 0) + { + yield return body[start..i]; + start = i + 1; + } + } + + yield return body[start..]; + } + + /// Tirnakli/koseli parantezli bolumun son karakterinin dizinini dondurur. + private static int SkipQuoted(string sql, int index) + { + var open = sql[index]; + var close = open == '[' ? ']' : open; + + for (var i = index + 1; i < sql.Length; i++) + { + if (sql[i] != close) + continue; + + // '' ve "" kacis dizileri kapanis sayilmaz. + if (open != '[' && i + 1 < sql.Length && sql[i + 1] == close) + { + i++; + continue; + } + + return i; + } + + return sql.Length - 1; + } + + private static string Unquote(string value) + { + return value.Trim().Trim('[', ']', '"', ' '); + } + + private static string RemoveComments(string sql) + { + var withoutBlock = Regex.Replace(sql, @"/\*.*?\*/", " ", RegexOptions.Singleline); + + return Regex.Replace(withoutBlock, @"--[^\r\n]*", " "); + } +} diff --git a/api/src/Sozsoft.Platform.Application/ListForms/SqlTableSchemaComparer.cs b/api/src/Sozsoft.Platform.Application/ListForms/SqlTableSchemaComparer.cs new file mode 100644 index 00000000..405d3b7a --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/ListForms/SqlTableSchemaComparer.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Hedef veritabanindaki bir kolonun katalogdan (INFORMATION_SCHEMA.COLUMNS) okunan hali. +/// +/// Tip adi bilincli olarak tasinmaz: saglayici katalogu kendi kanonik adini raporlar +/// (PostgreSQL'de VARCHAR -> character varying, TIMESTAMP -> +/// timestamp without time zone), dolayisiyla script'teki yazilisla metin olarak +/// karsilastirmak her VARCHAR kolonunda sahte fark uretirdi. Karsilastirma yalnizca her iki +/// saglayicida da ayni anlami tasiyan olculer uzerinden yapilir. +/// +/// +public sealed record SqlLiveColumn(string Name, int? Length, int? Precision, int? Scale, bool IsNullable); + +/// +/// Hedefte olmayan, eklenmesi gereken kolon. calistirilmaya hazirdir. +/// doluysa kolon otomatik eklenemez: karar veriye bakmayi gerektirir +/// ve import durdurulur. +/// +public sealed record SqlMissingColumn(string ColumnName, string Statement, string? BlockingReason); + +/// +/// Bir tablo script'inin hedefteki canli semayla karsilastirma sonucu. +/// +/// eklenebilir (kayipsiz) degisikliklerdir; +/// ise var olan bir kolonun olcusunun degismesidir — daraltma veri keser, tip donusumu basarisiz +/// olabilir, bu yuzden otomatik uygulanmaz ve import durdurulur. +/// +/// +public sealed record SqlSchemaComparison( + string TableName, + string? SchemaName, + IReadOnlyList MissingColumns, + IReadOnlyList Differences, + IReadOnlyList ExtraColumns); + +/// Var olan bir kolonun hedefteki hali ile script'teki hali arasindaki fark. +public sealed record SqlColumnDifference(string ColumnName, string Description); + +/// +/// Bir seed script'inin kurdugu tabloyu hedefteki canli semayla karsilastirir. +/// +/// Uretilen scriptler IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END kalibiyla +/// sarilidir: hedefte tablo varsa CREATE blogu tamamen atlanir. Dolayisiyla eski semali bir +/// tabloya yeni bir kolon script'in kendisiyle hicbir zaman gelmez; ekran o kolonu bir alan +/// olarak tanimladigi icin acilista "gecersiz kolon" hatasi verir. Bu sessiz uyusmazligi kapatan +/// yer burasidir. +/// +/// +/// Ayni karsilastirma iki yerde kullanilir: import analizi kullaniciya ne olacagini gosterir, +/// import kapanisi eklenebilir kolonlari uygular. Ikinci bir karsilastirma kopyasi yazilmaz. +/// +/// +public static class SqlTableSchemaComparer +{ + /// + /// Script'i hedef semayla karsilastirir. Script bir tablo kurmuyorsa (procedure, view), + /// cozulemiyorsa ya da tablo hedefte hic yoksa null doner — bu durumlarda karsilastirilacak bir + /// sey yoktur ve tahmin yurutulmez. + /// + /// Tablonun canli kolonlari; tablo yoksa bos, okunamazsa null. + /// + /// Tabloda kayit var mi. Yalnizca varsayilani olmayan NOT NULL bir kolon eklenirken + /// sorulur: bos tabloya boyle bir kolon guvenle eklenir, dolu tabloya eklenemez. + /// + public static async Task CompareAsync( + string sql, + Func?>> tableColumnsAsync, + Func> tableHasRowsAsync) + { + var declaration = SqlCreateTableParser.Parse(sql); + if (declaration == null) + return null; + + IReadOnlyList? live; + try + { + live = await tableColumnsAsync(declaration.TableName); + } + catch (Exception) + { + live = null; + } + + // Tablo hedefte yok: script onu olusturacak. Sema okunamadiysa da bir sey iddia edilmez. + if (live == null || live.Count == 0) + return null; + + var liveByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var column in live) + liveByName[column.Name] = column; + + var missing = new List(); + var differences = new List(); + var declaredNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var declared in declaration.Columns) + { + declaredNames.Add(declared.Name); + + if (liveByName.TryGetValue(declared.Name, out var actual)) + { + var difference = DescribeDifference(declared, actual); + if (difference != null) + differences.Add(new SqlColumnDifference(declared.Name, difference)); + + continue; + } + + missing.Add(new SqlMissingColumn( + declared.Name, + $"ALTER TABLE {declaration.RawName} ADD {declared.Definition};", + await ResolveBlockingReasonAsync(declaration.TableName, declared, tableHasRowsAsync))); + } + + // Hedefteki fazla kolon ekrani bozmaz ve asla dusurulmez; yalnizca bilgi verilir. + var extras = live + .Where(x => !declaredNames.Contains(x.Name)) + .Select(x => x.Name) + .ToList(); + + return new SqlSchemaComparison( + declaration.TableName, declaration.SchemaName, missing, differences, extras); + } + + /// + /// Kolonun otomatik eklenmesini engelleyen neden; engel yoksa null. + /// + /// Varsayilani olmayan NOT NULL bir kolon dolu bir tabloya eklenemez — veritabani zaten + /// reddeder. Bu durumda ne deger yazilacagi is kararidir; platform tahmin etmez, durur. + /// + /// + private static async Task ResolveBlockingReasonAsync( + string tableName, + SqlColumnDeclaration declared, + Func> tableHasRowsAsync) + { + if (declared.IsNullable != false) + return null; + + if (declared.Definition.Contains("DEFAULT", StringComparison.OrdinalIgnoreCase)) + return null; + + bool? hasRows; + try + { + hasRows = await tableHasRowsAsync(tableName); + } + catch (Exception) + { + hasRows = null; + } + + // Bos tabloya guvenle eklenir. Kayit sayisi okunamadiysa risk alinmaz. + if (hasRows == false) + return null; + + return hasRows == null + ? "kolon NOT NULL ve varsayılan değeri yok; tabloda kayıt olup olmadığı okunamadı" + : "kolon NOT NULL ve varsayılan değeri yok, tabloda kayıt var"; + } + + /// + /// Var olan bir kolonun olculerini karsilastirir; fark yoksa null doner. Yalnizca her iki + /// saglayicida da ayni anlami tasiyan ve katalogdan kesin okunabilen olculer bakilir; tip + /// adi karsilastirilmaz (bkz. ). + /// + private static string? DescribeDifference(SqlColumnDeclaration declared, SqlLiveColumn actual) + { + var differences = new List(); + + if (declared.Length.HasValue && actual.Length.HasValue && declared.Length != actual.Length) + { + differences.Add( + $"uzunluk hedefte {Describe(actual.Length.Value)}, script'te {Describe(declared.Length.Value)}"); + } + + if (declared.Precision.HasValue && actual.Precision.HasValue && declared.Precision != actual.Precision) + differences.Add($"kesinlik hedefte {actual.Precision}, script'te {declared.Precision}"); + + if (declared.Scale.HasValue && actual.Scale.HasValue && declared.Scale != actual.Scale) + differences.Add($"ondalık hedefte {actual.Scale}, script'te {declared.Scale}"); + + if (declared.IsNullable.HasValue && declared.IsNullable != actual.IsNullable) + { + differences.Add( + $"boş değer hedefte {(actual.IsNullable ? "serbest" : "yasak")}, " + + $"script'te {(declared.IsNullable.Value ? "serbest" : "yasak")}"); + } + + return differences.Count == 0 ? null : string.Join("; ", differences); + } + + private static string Describe(int length) => length < 0 ? "MAX" : length.ToString(); +} diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs new file mode 100644 index 00000000..f1d21a53 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Sozsoft.Platform.Data.Seeds; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Export arsivine girecek tek bir dosya. doluysa dosya diskten degil +/// uretilen icerikten yazilir (paylasilan bir seed dosyasindan suzulmus satirlar); aksi halde +/// doludur. Ikisinden tam olarak biri her zaman doludur. +/// +public sealed record WizardExportItem(string Path, string Reason, string? FullPath, string? Content); + +/// +/// Bir wizard seed dosyasinin tasinabilir olmasi icin gereken butun seed dosyalarini toplar. +/// +/// Kapanis ozyinelemelidir: ekranin kendi tablosu ve verisinin yaninda alt formlarinin wizard +/// dosyalari, onlarin bagimliliklari ve lookup sorgularinin isaret ettigi nesnelerin scriptleri de +/// toplanir. Aksi halde arsiv baska bir ortamda acildiginda ekran gelir ama alt form sekmeleri bos, +/// lookup'lar hatali kalir — yarim kurulumun en sik nedeni budur. +/// +/// +/// Bulunamayan bagimliliklar sessizce atlanmaz; uzerinden cagirana tasinir ve +/// indirmeden once kullaniciya gosterilir. +/// +/// +public sealed class WizardExportCollector(string scopeRoot) +{ + /// Custom component'lerin seed kaynagi olan ListForm ekrani. + public const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents; + + /// Custom component satirlarinin anahtar alani; bagimliliklar bu adla eslesir. + public const string CustomComponentNameField = "Name"; + + private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true }; + + /// + /// Lookup sorgusundaki tablo/gorunum adlari. Alias ve alt sorgular da eslesebilir; bulunan ad + /// icin diskte dosya aranir, yoksa sessizce gecilir — bu yuzden fazla eslesme zararsizdir. + /// + private static readonly Regex QueryObjectPattern = new( + @"\b(?:FROM|JOIN)\s+(?(?:\[[^\]]+\]|""[^""]+""|\w+)(?:\s*\.\s*(?:\[[^\]]+\]|""[^""]+""|\w+))*)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly string scopeRoot = System.IO.Path.GetFullPath(scopeRoot); + private readonly Dictionary items = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet customComponents = new(StringComparer.OrdinalIgnoreCase); + private readonly List warnings = []; + + /// ListFormCode -> wizard seed dosyasi; alt form cozumu icin bir kez kurulur. + private Dictionary? wizardFilesByListFormCode; + + public IReadOnlyList Warnings => warnings; + + /// Verilen wizard seed dosyasinin tasinabilir kapanisini toplar. + public async Task> CollectAsync(string wizardFilePath) + { + var pending = new Queue<(string FilePath, string Reason)>(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + pending.Enqueue((wizardFilePath, "Wizard")); + + while (pending.Count > 0) + { + var (filePath, reason) = pending.Dequeue(); + if (!visited.Add(System.IO.Path.GetFullPath(filePath))) + continue; + + var seed = await ReadWizardAsync(filePath); + if (seed?.Wizard == null) + { + warnings.Add($"{System.IO.Path.GetFileName(filePath)}: wizard dosyası okunamadı, bağımlılıkları toplanamadı."); + continue; + } + + AddDiskFile($"{SeedPathResolver.WizardFolder}/{System.IO.Path.GetFileName(filePath)}", reason); + + if (seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom) + await CollectCustomAsync(seed.Wizard.CustomComponentName, seed.Wizard.WizardName); + else + CollectList(seed, pending); + } + + await MaterializeCustomComponentsAsync(); + + return [.. items.Values.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)]; + } + + // ── List yolu ────────────────────────────────────────────────────────────────── + + /// + /// Ekranin tablosu, verisi, lookup nesneleri ve alt formlari. Alt formlar kuyruga eklenir; + /// kendi bagimliliklari da ayni kurallarla toplanir. + /// + private void CollectList(WizardSeedFileDto seed, Queue<(string FilePath, string Reason)> pending) + { + var listForm = seed.ListForm; + var commandType = listForm?.SelectCommandType ?? seed.Wizard.SelectCommandType; + var selectCommand = listForm?.SelectCommand ?? seed.Wizard.SelectCommand; + var screen = seed.Wizard.WizardName; + + // Query tipinde SelectCommand bir nesne adi degil ham SQL'dir; icindeki nesneler taranir. + if (commandType == SelectCommandTypeEnum.Query) + { + foreach (var name in ExtractQueryObjectNames(selectCommand)) + AddDatabaseObject(name, $"{screen} sorgu nesnesi"); + } + else + { + var objectName = NormalizeDatabaseObjectName(selectCommand); + if (objectName != null) + AddDatabaseObject(objectName, $"{screen} tablosu"); + } + + // Ekranin verisi seed dosyasina aynalaniyorsa arsiv onsuz eksiktir. + var seedFilePath = listForm?.SeedFilePath; + if (!string.IsNullOrWhiteSpace(seedFilePath) && !AddDiskFile(seedFilePath, $"{screen} verisi")) + warnings.Add($"{seedFilePath}: ekranın veri seed dosyası bulunamadı."); + + foreach (var field in seed.Fields ?? []) + { + LookupDto? lookup; + try + { + lookup = field.LookupDto; + } + catch (JsonException) + { + warnings.Add($"{screen}/{field.FieldName}: lookup tanımı çözülemedi, bağımlılıkları toplanamadı."); + continue; + } + + if (lookup?.DataSourceType != UiLookupDataSourceTypeEnum.Query) + continue; + + foreach (var name in ExtractQueryObjectNames(lookup.LookupQuery)) + AddDatabaseObject(name, $"{field.FieldName} lookup"); + } + + foreach (var subForm in ReadSubForms(seed)) + { + var code = subForm?.Code?.Trim(); + if (string.IsNullOrWhiteSpace(code)) + continue; + + var subFormFile = ResolveWizardFileByListFormCode(code); + if (subFormFile == null) + { + warnings.Add($"{code}: alt formun wizard dosyası bulunamadı, arşive eklenemedi."); + continue; + } + + pending.Enqueue((subFormFile, $"{screen} alt formu")); + } + } + + private SubFormDto[] ReadSubForms(WizardSeedFileDto seed) + { + try + { + return seed.ListForm?.SubFormsDto ?? []; + } + catch (JsonException) + { + warnings.Add($"{seed.Wizard.WizardName}: alt form tanımı çözülemedi, alt formlar arşive eklenemedi."); + return []; + } + } + + // ── Custom yolu ──────────────────────────────────────────────────────────────── + + /// + /// Component'i ve bagimlilik zincirini toplar; her component'in data source'larinin isaret + /// ettigi CRUD dosyasi ve o entity'nin tablosunu kuran script de arsive girer. Zincir dongu + /// icerse bile ziyaret edilen adlar takip edildigi icin sonsuz dongu olusmaz. + /// + private async Task CollectCustomAsync(string? componentName, string? wizardName) + { + if (string.IsNullOrWhiteSpace(componentName)) + return; + + var rows = await ReadCustomComponentRowsAsync(); + if (rows == null) + return; + + var pending = new Queue(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + pending.Enqueue(componentName.Trim()); + + var found = false; + + while (pending.Count > 0) + { + var name = pending.Dequeue(); + if (!visited.Add(name)) + continue; + + if (!rows.TryGetValue(name, out var row)) + { + warnings.Add($"{name}: custom component seed satırı bulunamadı, arşive eklenemedi."); + continue; + } + + found = true; + customComponents.Add(name); + + foreach (var dependency in ParseSeedRowList(GetRowText(row, "Dependencies"))) + { + if (!string.IsNullOrWhiteSpace(dependency)) + pending.Enqueue(dependency.Trim()); + } + + foreach (var dataSource in ParseSeedRowList(GetRowText(row, "DataSources"))) + { + // SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur; + // elle yazilmis bir URL'in seed karsiligi yoktur. + if (string.IsNullOrWhiteSpace(dataSource.SeedFile)) + continue; + + if (!AddDiskFile(dataSource.SeedFile, $"{name} veri kaynağı")) + { + warnings.Add($"{dataSource.SeedFile}: CRUD seed dosyası bulunamadı."); + continue; + } + + // Endpoint'in dayandigi tabloyu kuran script olmadan hedef ortamda endpoint bos duser. + var entityName = System.IO.Path.GetFileNameWithoutExtension(dataSource.SeedFile); + AddDatabaseObject(entityName, $"{name} tablosu", includeCrud: false); + } + } + + if (!found) + warnings.Add($"{componentName}: '{wizardName}' wizard'ının custom component tanımı bulunamadı."); + } + + /// + /// Toplanan component'ler paylasilan seed dosyasinin yalnizca ilgili satirlarina indirgenmis bir + /// kopyasi olarak arsive girer; arsiv kapsamdaki ilgisiz component'leri disari tasimaz. + /// + private async Task MaterializeCustomComponentsAsync() + { + if (customComponents.Count == 0) + return; + + var seedFile = await ReadCustomComponentFileAsync(); + if (seedFile == null) + return; + + // Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla birlestirilir. + seedFile.Rows = [.. seedFile.Rows.Where(x => customComponents.Contains(GetRowText(x, CustomComponentNameField) ?? string.Empty))]; + + var relativePath = SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode); + var content = JsonSerializer.Serialize(seedFile, ListFormSeedDataFileDto.JsonOptions); + + items[relativePath] = new WizardExportItem(relativePath, "Custom component tanımı", null, content); + } + + private async Task>?> ReadCustomComponentRowsAsync() + { + var seedFile = await ReadCustomComponentFileAsync(); + if (seedFile == null) + return null; + + var rows = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var row in seedFile.Rows) + { + var name = GetRowText(row, CustomComponentNameField); + if (!string.IsNullOrWhiteSpace(name)) + rows[name] = row; + } + + return rows; + } + + private async Task ReadCustomComponentFileAsync() + { + var relativePath = SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode); + var sourcePath = ResolveScopeFilePath(relativePath); + if (sourcePath == null) + { + warnings.Add($"{relativePath}: custom component seed dosyası bulunamadı."); + return null; + } + + try + { + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(sourcePath), JsonReadOptions); + } + catch (JsonException) + { + warnings.Add($"{relativePath}: custom component seed dosyası çözülemedi."); + return null; + } + } + + // ── Dosya toplama ────────────────────────────────────────────────────────────── + + /// + /// Bir veritabani nesnesinin scriptini ve (istenirse) CRUD tanimini arsive ekler. + /// + /// Nesnenin hangi saglayici/klasor altinda durdugu seed dosyasinda tutulmaz; bu yuzden kombinasyon + /// tahmin edilmez, kapsamdaki script klasorleri diskten taranir () + /// ve nesne adiyla eslesen her dosya arsive girer. Yeni bir saglayici ya da script klasoru + /// eklendiginde burasi degismez. + /// + /// + /// Script bulunamamasi hata degildir ve uyari uretmez: her tablo seed ile yonetilmez — + /// ABP'nin kendi tablolari (AbpUsers, AbpRoles …) EF migration'lariyla kurulur ve + /// seed script'leri yoktur. "Bu tablo hedefte var mi" sorusunun tek dogru cevabi hedef + /// veritabanindadir; karari import analizi verir (), export + /// varsayimda bulunmaz. + /// + /// + private void AddDatabaseObject(string? objectName, string reason, bool includeCrud = true) + { + if (string.IsNullOrWhiteSpace(objectName)) + return; + + foreach (var scriptFolder in ScriptFolders) + AddDiskFile($"{scriptFolder}/{objectName}.sql", reason); + + if (includeCrud) + AddDiskFile($"{SeedPathResolver.CrudFolder}/{objectName}.json", $"{reason} CRUD tanımı"); + } + + /// + /// Kapsamdaki script klasorlerinin kapsam-goreli yollari (sql/object, postgres/execute …), + /// diskte gercekten var olanlar. Bir kez taranir; kapsam kokunun disina cikilmaz. + /// + private IReadOnlyList ScriptFolders => scriptFolders ??= ScanScriptFolders(); + + private IReadOnlyList? scriptFolders; + + private List ScanScriptFolders() + { + var folders = new List(); + + foreach (var providerPath in SafeEnumerateDirectories(scopeRoot)) + { + var provider = System.IO.Path.GetFileName(providerPath); + + // Seed kokunun altinda script disi klasorler de var (wizard, crud, data); onlarin + // altinda .sql aranmaz. + if (provider.Equals(SeedPathResolver.WizardFolder, StringComparison.OrdinalIgnoreCase) + || provider.Equals(SeedPathResolver.CrudFolder, StringComparison.OrdinalIgnoreCase) + || provider.Equals(SeedPathResolver.DataFolder, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (var scriptPath in SafeEnumerateDirectories(providerPath)) + folders.Add($"{provider}/{System.IO.Path.GetFileName(scriptPath)}"); + } + + return folders; + } + + private static IEnumerable SafeEnumerateDirectories(string path) + { + return Directory.Exists(path) ? Directory.EnumerateDirectories(path) : []; + } + + /// Kapsam icindeki var olan bir dosyayi arsive ekler; kapsam disina cikan ya da bulunmayan yol icin false doner. + private bool AddDiskFile(string? relativePath, string reason) + { + var normalized = relativePath?.Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(normalized)) + return false; + + var fullPath = ResolveScopeFilePath(normalized); + if (fullPath == null) + return false; + + // Ayni dosya birden fazla yoldan gelebilir; ilk gerekce korunur. + if (!items.ContainsKey(normalized)) + items[normalized] = new WizardExportItem(normalized, reason, fullPath, null); + + return true; + } + + private string? ResolveScopeFilePath(string? relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) || relativePath.Contains("..", StringComparison.Ordinal)) + return null; + + var fullPath = System.IO.Path.GetFullPath( + System.IO.Path.Combine(scopeRoot, relativePath.Replace('/', System.IO.Path.DirectorySeparatorChar))); + + if (!fullPath.StartsWith(scopeRoot + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(fullPath) ? fullPath : null; + } + + private string? ResolveWizardFileByListFormCode(string listFormCode) + { + wizardFilesByListFormCode ??= BuildWizardIndex(scopeRoot); + + return wizardFilesByListFormCode.GetValueOrDefault(listFormCode); + } + + /// + /// Kapsamdaki wizard dosyalarini ekran koduna gore indeksler. Okunamayan dosya indekse girmez; + /// alt form cozumu sirasinda "bulunamadi" uyarisi uretir. + /// + public static Dictionary BuildWizardIndex(string scopeRoot) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + var wizardFolder = System.IO.Path.Combine(scopeRoot, SeedPathResolver.WizardFolder); + + if (!Directory.Exists(wizardFolder)) + return index; + + foreach (var filePath in Directory.GetFiles(wizardFolder, "*.json")) + { + WizardSeedFileDto? seed; + try + { + seed = WizardSeedFileDto.FromSeedJson(File.ReadAllText(filePath)); + } + catch (Exception) + { + continue; + } + + var code = seed?.Wizard?.ResolveListFormCode(); + if (!string.IsNullOrWhiteSpace(code)) + index.TryAdd(code, filePath); + } + + return index; + } + + private static async Task ReadWizardAsync(string filePath) + { + try + { + return WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath)); + } + catch (Exception) + { + return null; + } + } + + // ── Ortak yardimcilar ────────────────────────────────────────────────────────── + + /// Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur. + public static string? NormalizeDatabaseObjectName(string? selectCommand) + { + var trimmed = selectCommand?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + return null; + + var name = trimmed.Split('.')[^1].Trim('"', '[', ']', ' '); + if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(System.IO.Path.GetInvalidFileNameChars()) >= 0) + return null; + + return name; + } + + /// Ham bir SQL sorgusundaki FROM/JOIN nesnelerinin adlarini (sema oneki ayiklanmis) dondurur. + public static IEnumerable ExtractQueryObjectNames(string? sql) + { + if (string.IsNullOrWhiteSpace(sql)) + yield break; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in QueryObjectPattern.Matches(sql)) + { + var name = NormalizeDatabaseObjectName(match.Groups["name"].Value); + if (name != null && seen.Add(name)) + yield return name; + } + } + + /// Seed satirindaki bir alani metin olarak okur; JsonElement ve duz deger destekler. + public static string? GetRowText(Dictionary? row, string fieldName) + { + if (row == null || !row.TryGetValue(fieldName, out var value) || value is null) + return null; + + return value is JsonElement element + ? (element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString()) + : value.ToString(); + } + + /// Seed satirinda JSON dizisi olarak tutulan alanlari cozer; bozuk deger bos liste doner. + public static List ParseSeedRowList(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return []; + + try + { + return JsonSerializer.Deserialize>(json, JsonReadOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } +} diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs new file mode 100644 index 00000000..7995593f --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardImportValidator.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Sozsoft.Platform.Data.Seeds; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Import edilecek zip'in staging'e acilmis girdileri: kapsam-goreli yol ve dosya icerigi. +/// +public sealed record WizardImportStagedEntry(string Path, byte[] Content); + + +/// +/// Zip'in hedef ortamda yarim ya da bozuk bir kuruluma yol acip acmayacagini denetler. +/// +/// Dosyalar hedefe yazilmadan once calisir. Bir CRUD tanimi dayandigi tablo olmadan, bir veri +/// dosyasi ekrani olmadan, bir alt form referansi karsiligi olmadan yazilirsa ortada calismayan +/// bir konfigurasyon kalir; bu tur bulgular ile +/// raporlanir ve yazma adimi baslatilamaz. Kurulumu bozmayan ama dikkat isteyen durumlar uyaridir. +/// +/// +/// Eksik tablo icin olcut "veritabaninda var" degil, "veritabaninda var ya da zip onu +/// olusturan scripti tasiyor"dur: import kapanisi SQL scriptlerini veri satirlarindan once +/// calistirir, dolayisiyla zip'te scripti olan bir tablo eksik sayilmaz. +/// +/// +public sealed class WizardImportValidator( + string scopeRoot, + string providerFolder, + Func> databaseObjectExistsAsync, + Func?>> tableColumnsAsync, + Func> tableHasRowsAsync, + Func> listFormExistsAsync) +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly string scopeRoot = System.IO.Path.GetFullPath(scopeRoot); + private readonly List issues = []; + + /// Zip'teki wizard dosyalarinin urettigi ekran kodlari; veri ve alt form kontrolleri buna bakar. + private readonly HashSet incomingListFormCodes = new(StringComparer.OrdinalIgnoreCase); + + /// Zip'teki scriptlerden turetilen nesne adlari; eksik tablo kontrolu buna bakar. + private readonly HashSet incomingScriptObjects = new(StringComparer.OrdinalIgnoreCase); + + /// Zip'in getirdigi custom component adlari. + private readonly HashSet incomingCustomComponents = new(StringComparer.OrdinalIgnoreCase); + + /// Hedefteki custom component adlari; dosya her kontrolde yeniden okunmaz. + private HashSet? targetCustomComponents; + + public async Task> ValidateAsync(IReadOnlyCollection entries) + { + var wizards = new List<(string Path, WizardSeedFileDto Seed)>(); + var scripts = new List(); + var dataFiles = new List<(string Path, ListFormSeedDataFileDto File)>(); + var crudFiles = new List<(string Path, CrudEndpointSeedFileDto File)>(); + var paths = new HashSet(entries.Select(x => x.Path), StringComparer.OrdinalIgnoreCase); + + foreach (var entry in entries) + { + if (entry.Path.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) + { + incomingScriptObjects.Add(System.IO.Path.GetFileNameWithoutExtension(entry.Path)); + + // Sema karsilastirmasi yalnizca bu ortamda gercekten calistirilacak scriptler icin + // anlamlidir; karsi saglayicinin scripti hic uygulanmaz. + if (entry.Path.StartsWith(providerFolder + "/", StringComparison.OrdinalIgnoreCase)) + scripts.Add(entry); + + continue; + } + + if (entry.Path.StartsWith(SeedPathResolver.WizardFolder + "/", StringComparison.OrdinalIgnoreCase)) + { + var seed = ReadWizard(entry); + if (seed != null) + { + wizards.Add((entry.Path, seed)); + + var code = seed.Wizard.ResolveListFormCode(); + if (!string.IsNullOrWhiteSpace(code)) + incomingListFormCodes.Add(code); + } + + continue; + } + + if (entry.Path.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase)) + { + var file = Deserialize(entry); + if (file != null) + { + dataFiles.Add((entry.Path, file)); + + if (IsCustomComponentsFile(entry.Path, file)) + { + foreach (var row in file.Rows) + { + var name = WizardExportCollector.GetRowText(row, WizardExportCollector.CustomComponentNameField); + if (!string.IsNullOrWhiteSpace(name)) + incomingCustomComponents.Add(name); + } + } + } + + continue; + } + + if (entry.Path.StartsWith(SeedPathResolver.CrudFolder + "/", StringComparison.OrdinalIgnoreCase)) + { + var file = Deserialize(entry); + if (file != null) + crudFiles.Add((entry.Path, file)); + } + } + + if (wizards.Count == 0 && !paths.Any(x => x.StartsWith(SeedPathResolver.WizardFolder + "/", StringComparison.OrdinalIgnoreCase))) + Add(WizardImportIssueSeverityEnum.Warning, null, "Zip içinde wizard dosyası yok; yalnızca bağımlılık dosyaları aktarılacak."); + + ValidateProvider(paths); + + foreach (var (path, seed) in wizards) + await ValidateWizardAsync(path, seed, paths); + + foreach (var (path, file) in crudFiles) + await ValidateCrudAsync(path, file); + + foreach (var (path, file) in dataFiles) + await ValidateDataAsync(path, file, paths); + + foreach (var script in scripts) + await ValidateTableSchemaAsync(script); + + return [.. issues.OrderBy(x => x.Severity).ThenBy(x => x.Path, StringComparer.OrdinalIgnoreCase)]; + } + + // ── Kurallar ─────────────────────────────────────────────────────────────────── + + /// + /// Zip yalnizca karsi saglayicinin scriptlerini tasiyorsa scriptler hedefte hic calismaz; + /// tablolar eksik kalir. Kurulumu tek basina bozmadigi (tablo zaten var olabilir) icin uyaridir. + /// + private void ValidateProvider(HashSet paths) + { + var otherFolder = providerFolder == SeedPathResolver.SqlFolder + ? SeedPathResolver.PostgresFolder + : SeedPathResolver.SqlFolder; + + var hasCurrent = paths.Any(x => x.StartsWith(providerFolder + "/", StringComparison.OrdinalIgnoreCase)); + var hasOther = paths.Any(x => x.StartsWith(otherFolder + "/", StringComparison.OrdinalIgnoreCase)); + + if (hasOther && !hasCurrent) + { + Add(WizardImportIssueSeverityEnum.Warning, null, + $"Zip yalnızca '{otherFolder}' sağlayıcısının scriptlerini içeriyor; bu ortam '{providerFolder}' kullanıyor ve scriptler çalıştırılmayacak."); + } + } + + private async Task ValidateWizardAsync(string path, WizardSeedFileDto seed, HashSet paths) + { + var wizard = seed.Wizard; + var screen = string.IsNullOrWhiteSpace(wizard.WizardName) ? path : wizard.WizardName; + + if (wizard.ComponentKind == WizardComponentKindEnum.Custom) + { + if (string.IsNullOrWhiteSpace(wizard.CustomComponentName)) + Add(WizardImportIssueSeverityEnum.Error, path, $"{screen}: custom component adı boş."); + + if (string.IsNullOrWhiteSpace(wizard.MenuUrl)) + Add(WizardImportIssueSeverityEnum.Error, path, $"{screen}: custom component rota yolu boş; menü hiçbir yere gitmez."); + + ValidateCustomComponent(path, wizard.CustomComponentName); + return; + } + + if (seed.ListForm == null) + { + Add(WizardImportIssueSeverityEnum.Error, path, $"{screen}: ListForm bölümü eksik, ekran kurulamaz."); + return; + } + + var commandType = seed.ListForm.SelectCommandType; + var dataSourceCode = seed.ListForm.DataSourceCode; + + // StoredProcedure ve ham sorgu icin tek bir nesne adi yoktur; nesne kontrolu yapilamaz. + if (commandType == SelectCommandTypeEnum.Table || commandType == SelectCommandTypeEnum.View) + { + var objectName = WizardExportCollector.NormalizeDatabaseObjectName(seed.ListForm.SelectCommand); + if (objectName == null) + { + Add(WizardImportIssueSeverityEnum.Error, path, $"{screen}: veri kaynağı nesnesi (SelectCommand) boş."); + } + else + { + // View icin tablo tasarimcisi dogru arac degil; yalnizca tabloda onarim yolu sunulur. + await RequireDatabaseObjectAsync( + path, dataSourceCode, objectName, $"{screen} ekranının tablosu", + commandType == SelectCommandTypeEnum.Table ? objectName : null); + } + } + + var seedFilePath = seed.ListForm.SeedFilePath; + if (!string.IsNullOrWhiteSpace(seedFilePath) + && !paths.Contains(seedFilePath) + && !ScopeFileExists(seedFilePath)) + { + Add(WizardImportIssueSeverityEnum.Warning, path, + $"{screen}: ekranın veri dosyası ({seedFilePath}) ne zip'te ne hedefte var; ekran boş gelecek."); + } + + foreach (var subForm in ReadSubForms(seed, path)) + { + var code = subForm?.Code?.Trim(); + if (string.IsNullOrWhiteSpace(code) || incomingListFormCodes.Contains(code)) + continue; + + if (!await listFormExistsAsync(code)) + { + Add(WizardImportIssueSeverityEnum.Error, path, + $"{screen}: '{code}' alt formu ne zip'te ne hedefte var; sekme boş açılır."); + } + } + } + + /// CRUD tanimi, dayandigi tablo olmadan yazilirsa endpoint calisma aninda hata verir. + private async Task ValidateCrudAsync(string path, CrudEndpointSeedFileDto file) + { + var entityName = string.IsNullOrWhiteSpace(file.EntityName) + ? System.IO.Path.GetFileNameWithoutExtension(path) + : file.EntityName.Trim(); + + if (file.Endpoints == null || file.Endpoints.Count == 0) + { + Add(WizardImportIssueSeverityEnum.Warning, path, $"{entityName}: CRUD dosyasında endpoint yok."); + return; + } + + await RequireDatabaseObjectAsync( + path, null, entityName, $"{entityName} CRUD endpoint'lerinin tablosu", entityName); + } + + /// Veri dosyasi, satirlarin yazilacagi ekran tanimi olmadan uygulanamaz. + private async Task ValidateDataAsync(string path, ListFormSeedDataFileDto file, HashSet paths) + { + var listFormCode = string.IsNullOrWhiteSpace(file.ListFormCode) + ? System.IO.Path.GetFileNameWithoutExtension(path) + : file.ListFormCode.Trim(); + + if (!incomingListFormCodes.Contains(listFormCode) && !await listFormExistsAsync(listFormCode)) + { + Add(WizardImportIssueSeverityEnum.Error, path, + $"'{listFormCode}' ekranı ne zip'te ne hedefte var; veri satırları uygulanamaz."); + } + + if (file.Rows.Count == 0) + Add(WizardImportIssueSeverityEnum.Warning, path, $"{listFormCode}: veri dosyasında satır yok."); + + if (IsCustomComponentsFile(path, file)) + ValidateCustomComponentRows(path, file, paths); + } + + /// + /// Zip'in getirdigi tablo scriptini hedefteki canli semayla karsilastirir ve sonucu bulguya + /// cevirir. Karsilastirmanin kendisi icindedir; ayni + /// karsilastirmayi import kapanisi da kullanir. + /// + /// Ayrim kayip riskine gore yapilir: eksik kolon eklenebilir (kayipsiz) — uyari olarak + /// bildirilir ve kapanista uygulanir. Var olan bir kolonun olcusu degismisse durulur — + /// daraltma veri keser, tip donusumu basarisiz olabilir; dogru islem veriye bakmayi gerektirir. + /// + /// + private async Task ValidateTableSchemaAsync(WizardImportStagedEntry entry) + { + var comparison = await SqlTableSchemaComparer.CompareAsync( + System.Text.Encoding.UTF8.GetString(entry.Content), tableColumnsAsync, tableHasRowsAsync); + + if (comparison == null) + return; + + foreach (var difference in comparison.Differences) + { + issues.Add(new WizardImportIssueDto + { + Severity = WizardImportIssueSeverityEnum.Error, + Path = entry.Path, + TableName = comparison.TableName, + SchemaName = comparison.SchemaName, + Message = + $"{comparison.TableName}.{difference.ColumnName}: {difference.Description}. " + + "Platform var olan bir kolonu değiştirmez — daraltma veri keser, tip dönüşümü başarısız " + + "olabilir. Kolonu tablo tasarımcısından düzenleyip import'u tekrar başlatın.", + }); + } + + foreach (var column in comparison.MissingColumns) + { + if (column.BlockingReason == null) + { + issues.Add(new WizardImportIssueDto + { + Severity = WizardImportIssueSeverityEnum.Warning, + Path = entry.Path, + TableName = comparison.TableName, + SchemaName = comparison.SchemaName, + Message = + $"{comparison.TableName}.{column.ColumnName}: kolon hedefte yok, import tamamlanırken eklenecek. " + + "Tablo zaten var olduğu için script'in CREATE bloğu çalışmaz; kolon aşağıdaki cümleyle eklenir.", + Statement = column.Statement, + }); + + continue; + } + + issues.Add(new WizardImportIssueDto + { + Severity = WizardImportIssueSeverityEnum.Error, + Path = entry.Path, + TableName = comparison.TableName, + SchemaName = comparison.SchemaName, + Message = + $"{comparison.TableName}.{column.ColumnName}: kolon hedefte yok ve otomatik eklenemez — " + + $"{column.BlockingReason}. Kolona hangi değerin yazılacağı bir iş kararıdır; kolonu tablo " + + "tasarımcısından bir varsayılan değerle ya da NULL kabul edecek şekilde ekleyip import'u " + + "tekrar başlatın.", + Statement = column.Statement, + }); + } + + if (comparison.ExtraColumns.Count > 0) + { + issues.Add(new WizardImportIssueDto + { + Severity = WizardImportIssueSeverityEnum.Warning, + Path = entry.Path, + TableName = comparison.TableName, + SchemaName = comparison.SchemaName, + Message = + $"{comparison.TableName}: hedefteki şu kolonlar script'te yok, oldukları gibi bırakılacak — " + + $"{string.Join(", ", comparison.ExtraColumns)}.", + }); + } + } + + /// + /// Custom component satirlarinin bagimlilik zinciri ve veri kaynaklari. Eksik bir bagimlilik + /// tarayicida derleme hatasina, eksik bir CRUD dosyasi calismayan bir endpoint'e doner. + /// + private void ValidateCustomComponentRows(string path, ListFormSeedDataFileDto file, HashSet paths) + { + var existing = ReadTargetCustomComponentNames(); + + foreach (var row in file.Rows) + { + var name = WizardExportCollector.GetRowText(row, WizardExportCollector.CustomComponentNameField); + if (string.IsNullOrWhiteSpace(name)) + { + Add(WizardImportIssueSeverityEnum.Error, path, "Adı olmayan bir custom component satırı var."); + continue; + } + + foreach (var dependency in WizardExportCollector.ParseSeedRowList( + WizardExportCollector.GetRowText(row, "Dependencies"))) + { + var trimmed = dependency?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || incomingCustomComponents.Contains(trimmed) || existing.Contains(trimmed)) + continue; + + Add(WizardImportIssueSeverityEnum.Error, path, + $"{name}: '{trimmed}' bağımlılığı ne zip'te ne hedefte var."); + } + + foreach (var dataSource in WizardExportCollector.ParseSeedRowList( + WizardExportCollector.GetRowText(row, "DataSources"))) + { + var seedFile = dataSource.SeedFile?.Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(seedFile) || paths.Contains(seedFile) || ScopeFileExists(seedFile)) + continue; + + Add(WizardImportIssueSeverityEnum.Error, path, + $"{name}: veri kaynağının CRUD dosyası ({seedFile}) ne zip'te ne hedefte var."); + } + } + } + + /// Custom wizard'in bagli oldugu bilesenin tanimi zip'te ya da hedefte olmali. + private void ValidateCustomComponent(string wizardPath, string componentName) + { + var name = componentName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + return; + + if (incomingCustomComponents.Contains(name) || ReadTargetCustomComponentNames().Contains(name)) + return; + + Add(WizardImportIssueSeverityEnum.Error, wizardPath, + $"'{name}' bileşeninin tanımı ne zip'te ne hedefte var; menü boş bir rotaya bağlanır."); + } + + /// Veri dosyasinin custom component seed kaynagi olup olmadigi. + private static bool IsCustomComponentsFile(string path, ListFormSeedDataFileDto file) + { + var listFormCode = string.IsNullOrWhiteSpace(file.ListFormCode) + ? System.IO.Path.GetFileNameWithoutExtension(path) + : file.ListFormCode.Trim(); + + return string.Equals( + listFormCode, WizardExportCollector.CustomComponentsListFormCode, StringComparison.OrdinalIgnoreCase); + } + + // ── Yardimcilar ──────────────────────────────────────────────────────────────── + + /// + /// Nesne hedefte yoksa ve zip onu olusturan scripti tasimiyorsa hata uretir. Nesne varligi + /// sorgulanamadiginda (baglanti yok, izin yok) karar verilemez; bu durumda uyari birakilir. + /// + /// + /// Nesne bir tabloysa adi; bulguya islenir ve istemci tablo tasarimcisini olusturma modunda acar. + /// View gibi tasarimciyla kurulamayan nesnelerde null birakilir. + /// + private async Task RequireDatabaseObjectAsync( + string path, string? dataSourceCode, string objectName, string description, string? repairableTableName = null) + { + if (incomingScriptObjects.Contains(objectName)) + return; + + bool? exists; + try + { + exists = await databaseObjectExistsAsync(dataSourceCode, objectName); + } + catch (Exception) + { + exists = null; + } + + if (exists == true) + return; + + if (exists == null) + { + Add(WizardImportIssueSeverityEnum.Warning, path, + $"{description} ('{objectName}') veritabanında doğrulanamadı; scripti zip'te de yok."); + return; + } + + issues.Add(new WizardImportIssueDto + { + Severity = WizardImportIssueSeverityEnum.Error, + Path = path, + TableName = repairableTableName, + TableMissing = repairableTableName != null, + // Tablo bu ekrandan olusturulabilir; view olusturulamaz, tek yol scriptiyle birlikte + // yeniden export etmektir. + RequiresReexport = repairableTableName == null, + Message = $"{description} ('{objectName}') veritabanında yok ve zip onu oluşturan .sql dosyasını içermiyor." + + (repairableTableName != null + ? " Tabloyu tablo tasarımcısından oluşturup import'a devam edebilirsiniz." + : string.Empty), + }); + } + + private HashSet ReadTargetCustomComponentNames() + { + if (targetCustomComponents != null) + return targetCustomComponents; + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var relativePath = SeedPathResolver.GetDataFilePath(WizardExportCollector.CustomComponentsListFormCode); + var fullPath = ResolveScopeFilePath(relativePath); + + if (fullPath == null) + return targetCustomComponents = names; + + try + { + var file = JsonSerializer.Deserialize(File.ReadAllText(fullPath), JsonReadOptions); + foreach (var row in file?.Rows ?? []) + { + var name = WizardExportCollector.GetRowText(row, WizardExportCollector.CustomComponentNameField); + if (!string.IsNullOrWhiteSpace(name)) + names.Add(name); + } + } + catch (JsonException) + { + // Hedef dosya bozuksa import bunu duzeltmek icin yapiliyor olabilir; kontrol atlanir. + } + + return targetCustomComponents = names; + } + + private SubFormDto[] ReadSubForms(WizardSeedFileDto seed, string path) + { + try + { + return seed.ListForm?.SubFormsDto ?? []; + } + catch (JsonException) + { + Add(WizardImportIssueSeverityEnum.Error, path, "Alt form tanımı (SubFormsJson) çözülemedi."); + return []; + } + } + + private WizardSeedFileDto? ReadWizard(WizardImportStagedEntry entry) + { + try + { + var seed = WizardSeedFileDto.FromSeedJson(System.Text.Encoding.UTF8.GetString(entry.Content)); + if (seed?.Wizard == null || string.IsNullOrWhiteSpace(seed.Wizard.WizardName)) + { + Add(WizardImportIssueSeverityEnum.Error, entry.Path, "Geçerli bir wizard dosyası değil (Wizard bölümü ya da adı yok)."); + return null; + } + + return seed; + } + catch (Exception) + { + Add(WizardImportIssueSeverityEnum.Error, entry.Path, "Wizard dosyası okunamadı (bozuk JSON)."); + return null; + } + } + + private T? Deserialize(WizardImportStagedEntry entry) where T : class + { + try + { + var value = JsonSerializer.Deserialize(entry.Content, JsonReadOptions); + if (value == null) + Add(WizardImportIssueSeverityEnum.Error, entry.Path, "Dosya boş."); + + return value; + } + catch (JsonException) + { + Add(WizardImportIssueSeverityEnum.Error, entry.Path, "Dosya okunamadı (bozuk JSON)."); + return null; + } + } + + private bool ScopeFileExists(string? relativePath) => ResolveScopeFilePath(relativePath) != null; + + private string? ResolveScopeFilePath(string? relativePath) + { + var normalized = relativePath?.Replace('\\', '/').Trim('/'); + if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal)) + return null; + + var fullPath = System.IO.Path.GetFullPath( + System.IO.Path.Combine(scopeRoot, normalized.Replace('/', System.IO.Path.DirectorySeparatorChar))); + + if (!fullPath.StartsWith(scopeRoot + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + return null; + + return File.Exists(fullPath) ? fullPath : null; + } + + /// + /// Bulgu ekler. Bu yoldan gecen her , arsivin + /// kendi icerigindeki bir eksikliktir (bozuk dosya, eksik bolum, karsiligi olmayan referans) ve + /// hedef ortamda giderilemez; bu yuzden + /// isaretlenir. Ortamda giderilebilen engeller (eksik tablo, sema farki) bu yardimciyi + /// kullanmaz, bulgularini kendileri kurar. + /// + private void Add(WizardImportIssueSeverityEnum severity, string? path, string message) + { + issues.Add(new WizardImportIssueDto + { + Severity = severity, + Path = path, + Message = message, + RequiresReexport = severity == WizardImportIssueSeverityEnum.Error, + }); + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/CrudDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/CrudDataSeeder.cs index 11b281d3..0eb258ae 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/CrudDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/CrudDataSeeder.cs @@ -1,15 +1,6 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Sozsoft.Platform.Entities; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; namespace Sozsoft.Platform.Data.Seeds; @@ -22,126 +13,15 @@ namespace Sozsoft.Platform.Data.Seeds; /// garanti edilmedigi icin bilincli olarak degildir, /// SqlDataSeeder tarafindan dogrudan cagrilir. /// -/// Her dosya bir entity'ye aittir ve o entity'nin endpoint listesinin tamamini icerir: -/// dosyadaki liste kaynak kabul edilir, veritabanindaki eski kayitlar dosyaya gore guncellenir. +/// +/// Uygulama kurali icindedir; ayni kural wizard import'unun +/// kapanisinda da kullanilir, ikinci bir kopya yazilmaz. +/// /// -public class CrudDataSeeder : ITransientDependency +public class CrudDataSeeder(ICrudEndpointSeedApplier applier) : ITransientDependency { - private static readonly JsonSerializerOptions JsonReadOptions = new() + public Task SeedAsync(DataSeedContext context) { - PropertyNameCaseInsensitive = true - }; - - private readonly IRepository _repository; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - - public CrudDataSeeder( - IRepository repository, - IConfiguration configuration, - ILogger logger) - { - _repository = repository; - _configuration = configuration; - _logger = logger; - } - - public async Task SeedAsync(DataSeedContext context) - { - var scopeFolderName = SeedPathResolver.GetScopeFolderName(context.TenantId); - var crudDataPath = SeedPathResolver.GetScopePath(_configuration, context.TenantId, SeedPathResolver.CrudFolder); - - if (!Directory.Exists(crudDataPath)) - { - _logger.LogInformation("Seeds/{Scope}/crud directory not found, skipping CrudDataSeeder.", scopeFolderName); - return; - } - - var jsonFiles = Directory.GetFiles(crudDataPath, "*.json").OrderBy(Path.GetFileName).ToArray(); - if (jsonFiles.Length == 0) - { - _logger.LogInformation("No JSON files found in Seeds/{Scope}/crud directory, skipping CrudDataSeeder.", scopeFolderName); - return; - } - - _logger.LogInformation("CrudDataSeeder started. {Count} file(s) to be processed.", jsonFiles.Length); - - foreach (var filePath in jsonFiles) - { - var fileName = Path.GetFileName(filePath); - try - { - var json = await File.ReadAllTextAsync(filePath); - var seedFile = JsonSerializer.Deserialize(json, JsonReadOptions); - - var entityName = string.IsNullOrWhiteSpace(seedFile?.EntityName) - ? Path.GetFileNameWithoutExtension(filePath) - : seedFile!.EntityName.Trim(); - - if (seedFile?.Endpoints == null || seedFile.Endpoints.Count == 0) - { - _logger.LogWarning("[{File}] No endpoint found, skipped.", fileName); - continue; - } - - await ApplyAsync(entityName, seedFile.Endpoints); - _logger.LogInformation("[{File}] '{EntityName}' applied with {Count} endpoint(s).", fileName, entityName, seedFile.Endpoints.Count); - } - catch (Exception ex) - { - // Tek bir dosyanin hatasi diger endpoint'lerin seed edilmesini engellemez. - _logger.LogError(ex, "Failed to process CRUD seed file: {FileName}", fileName); - } - } - - _logger.LogInformation("CrudDataSeeder completed. {Count} file(s) processed.", jsonFiles.Length); - } - - private async Task ApplyAsync(string entityName, List endpoints) - { - var existing = await _repository.GetListAsync(x => x.EntityName == entityName); - - foreach (var item in endpoints) - { - if (string.IsNullOrWhiteSpace(item.OperationType)) - { - continue; - } - - var current = existing.FirstOrDefault(x => - string.Equals(x.OperationType, item.OperationType, StringComparison.OrdinalIgnoreCase)); - - if (current == null) - { - // CsharpCode seed dosyasinda tutulmaz; endpoint'in calismasi icin gerekli degildir, - // gerektiginde SqlQueryManager'daki "Yeniden Olustur" ile uretilir. - await _repository.InsertAsync(new CrudEndpoint - { - EntityName = entityName, - Method = item.Method, - Path = item.Path, - OperationType = item.OperationType, - IsActive = item.IsActive - }, autoSave: true); - continue; - } - - current.Method = item.Method; - current.Path = item.Path; - current.IsActive = item.IsActive; - await _repository.UpdateAsync(current, autoSave: true); - } - - // Dosyada artik yer almayan operasyonlar kaldirilir; dosya tek kaynaktir. - var seededOperations = endpoints - .Select(x => x.OperationType) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var obsolete = existing.Where(x => !seededOperations.Contains(x.OperationType)).ToList(); - if (obsolete.Count > 0) - { - await _repository.DeleteManyAsync(obsolete, autoSave: true); - } + return applier.ApplyScopeAsync(context.TenantId); } } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index afc13b40..1e41a390 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -27180,12 +27180,6 @@ "en": "Skipped entries", "tr": "Atlanan kayıtlar" }, - { - "resourceName": "Platform", - "key": "App.ListForm.WizardImportStart", - "en": "Start import", - "tr": "İçe aktarmayı başlat" - }, { "resourceName": "Platform", "key": "App.ListForm.WizardImportWriting", @@ -27212,9 +27206,171 @@ }, { "resourceName": "Platform", - "key": "App.ListForm.WizardImportRunMigrateHint", - "en": "Data rows were written to the database. Run DB Migrate for the remaining seed files (wizard, crud, sql).", - "tr": "Veri satırları veritabanına yazıldı. Kalan seed dosyaları (wizard, crud, sql) için DB Migrate çalıştırın." + "key": "App.ListForm.WizardImportStepFiles", + "en": "Files", + "tr": "Dosyalar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportStepChecks", + "en": "Checks", + "tr": "Kontroller" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportStepPlan", + "en": "Plan", + "tr": "Yapılacaklar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportStepApply", + "en": "Apply", + "tr": "Uygulama" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportNoIssues", + "en": "No problems were found in the checks.", + "tr": "Kontrollerde bir sorun bulunmadı." + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportEditTable", + "en": "Edit table", + "tr": "Tabloyu düzenle" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportCreateTable", + "en": "Create table", + "tr": "Tabloyu oluştur" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanTitle", + "en": "On confirmation the following will be done, in this order:", + "tr": "Onayladığınızda sırasıyla şunlar yapılacak:" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanNothing", + "en": "Nothing will be done; no file was selected.", + "tr": "Yapılacak bir işlem yok; hiçbir dosya seçilmedi." + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanFiles", + "en": "Files written to the scope folder", + "tr": "Kapsam klasörüne yazılacak dosyalar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanSql", + "en": "SQL scripts executed", + "tr": "Çalıştırılacak SQL script'leri" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanColumns", + "en": "Columns added to existing tables", + "tr": "Var olan tablolara eklenecek kolonlar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanCrud", + "en": "CRUD endpoint definitions applied", + "tr": "Uygulanacak CRUD endpoint tanımları" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanData", + "en": "Data files applied to the database", + "tr": "Veritabanına uygulanacak veri dosyaları" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportPlanWizards", + "en": "Screens to install (existing ones are left untouched)", + "tr": "Kurulacak ekranlar (zaten var olanlara dokunulmaz)" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportConfirmStart", + "en": "Confirm and start", + "tr": "Onayla ve başlat" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportBlockingIssues", + "en": "Import cannot start, these must be resolved first", + "tr": "İçe aktarma başlatılamaz, önce bunlar giderilmeli" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportReexportIssues", + "en": "Archive is incomplete or corrupt", + "tr": "Arşiv eksik ya da bozuk" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportReexportHint", + "en": "These cannot be fixed here — what is missing is not in the archive. Repeat the export in the source environment and import the new archive.", + "tr": "Bunlar burada giderilemez — eksik olan veri arşivde yok. Kaynak ortamda export'u tekrarlayıp yeni arşivi import edin." + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportAdvisoryIssues", + "en": "Points to check", + "tr": "Dikkat edilmesi gerekenler" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportBlocked", + "en": "The archive is missing dependencies required by the target environment.", + "tr": "Arşiv, hedef ortamın gerektirdiği bağımlılıkları taşımıyor." + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportSqlApplied", + "en": "SQL scripts executed", + "tr": "Çalıştırılan SQL scriptleri" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportSchemaApplied", + "en": "Columns added to existing tables", + "tr": "Var olan tablolara eklenen kolonlar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportCrudApplied", + "en": "CRUD endpoints applied", + "tr": "Uygulanan CRUD endpoint'leri" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardImportWizardsApplied", + "en": "Screens installed", + "tr": "Kurulan ekranlar" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardExportIncomplete", + "en": "Some dependencies could not be found. An incomplete archive is rejected on import and the export has to be repeated — fix the listed references first.", + "tr": "Bazı bağımlılıklar bulunamadı. Eksik bir arşiv import sırasında reddedilir ve export'un tekrarlanması gerekir — önce aşağıdaki referansları düzeltin." + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardExportContents", + "en": "Archive contents", + "tr": "Arşiv içeriği" + }, + { + "resourceName": "Platform", + "key": "App.ListForm.WizardExportDownloadAnyway", + "en": "Download incomplete archive", + "tr": "Eksik arşivi yine de indir" }, { "resourceName": "Platform", diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportIssueSeverityEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportIssueSeverityEnum.cs new file mode 100644 index 00000000..91c52209 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportIssueSeverityEnum.cs @@ -0,0 +1,17 @@ +namespace Sozsoft.Platform.Enums; + +/// Import analizinde bulunan bir tutarsizligin agirligi. +public enum WizardImportIssueSeverityEnum +{ + /// + /// Import edilirse yarim ya da bozuk bir kurulum olusur (eksik tablo, cozulemeyen bagimlilik, + /// bozuk dosya). Bu agirlikta bir bulgu varken dosya yazma adimi baslatilamaz. + /// + Error = 0, + + /// + /// Dikkat edilmesi gereken ama kurulumu bozmayan durum (saglayici uyusmazligi, hedefte ayni + /// kodla baska bir wizard olmasi). Import engellenmez. + /// + Warning = 1, +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/CrudEndpointSeedApplier.cs b/api/src/Sozsoft.Platform.Domain/Data/CrudEndpointSeedApplier.cs new file mode 100644 index 00000000..f9cd07c8 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/CrudEndpointSeedApplier.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Sozsoft.Platform.Entities; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Sozsoft.Platform.Data.Seeds; + +/// Bir CRUD seed dosyasinin uygulanma sonucu. +public record CrudEndpointSeedApplyResult(string Path, int Inserted, int Updated, int Deleted, int Skipped, string Error = null); + +/// +/// {host|tenants/{tenantId}}/crud/{EntityName}.json dosyalarini veritabanina uygular. +/// +public interface ICrudEndpointSeedApplier +{ + /// Kapsamdaki butun CRUD seed dosyalarini uygular (migrate/seed akisi). + Task> ApplyScopeAsync(Guid? tenantId); + + /// + /// Kapsam klasorune goreli belirli dosyalari uygular; import gibi dosya bazli akislar butun + /// klasoru taramak yerine yalnizca yazilan dosyalari uygular. + /// + Task> ApplyFilesAsync(Guid? tenantId, IReadOnlyCollection relativePaths); +} + +/// +/// CRUD endpoint tanimlarini seed dosyasindan veritabanina yazar. +/// +/// Politika damga karsilastirmalidir: eksik endpoint her zaman eklenir, ama var olan bir kayit +/// yalnizca dosyanin GeneratedAt damgasi kaydin son degisiklik zamanindan yeniyse +/// guncellenir ya da silinir. Boylece CRUD Endpoint Manager uzerinden yapilan bir revizyon, +/// depodan gelen eski bir dosya yuzunden migrate sirasinda geri alinmaz; dosya gercekten +/// yeniyse (baska bir ortamda uretilmis, import edilmis) degisiklik yayilir. +/// +/// +/// CsharpCode bilincli olarak dosyada tutulmaz: entity adi ve operasyondan yeniden +/// turetilebilir. Guncellemede alana dokunulmaz, eklemede bos kalir ve SqlQueryManager'daki +/// "Yeniden Olustur" ile uretilir. +/// +/// +public class CrudEndpointSeedApplier( + IRepository repository, + IConfiguration configuration, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + ILogger logger) : ICrudEndpointSeedApplier, ITransientDependency +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task> ApplyScopeAsync(Guid? tenantId) + { + var scopeFolderName = SeedPathResolver.GetScopeFolderName(tenantId); + var crudPath = SeedPathResolver.GetScopePath(configuration, tenantId, SeedPathResolver.CrudFolder); + + if (!Directory.Exists(crudPath)) + { + logger.LogInformation("Seeds/{Scope}/crud directory not found, skipping CrudEndpointSeedApplier.", scopeFolderName); + return []; + } + + var jsonFiles = Directory.GetFiles(crudPath, "*.json").OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase).ToArray(); + if (jsonFiles.Length == 0) + { + logger.LogInformation("No JSON files found in Seeds/{Scope}/crud directory, skipping CrudEndpointSeedApplier.", scopeFolderName); + return []; + } + + logger.LogInformation("CrudEndpointSeedApplier started for {Scope}. {Count} file(s) to be processed.", scopeFolderName, jsonFiles.Length); + + return await ApplyAsync(tenantId, jsonFiles); + } + + public async Task> ApplyFilesAsync(Guid? tenantId, IReadOnlyCollection relativePaths) + { + if (relativePaths == null || relativePaths.Count == 0) + return []; + + var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(configuration, tenantId)); + var results = new List(); + var filePaths = new List(); + + foreach (var relativePath in relativePaths) + { + var normalized = relativePath.Replace('\\', '/').Trim('/'); + var fullPath = normalized.Contains("..", StringComparison.Ordinal) + ? null + : Path.GetFullPath(Path.Combine(scopeRoot, normalized.Replace('/', Path.DirectorySeparatorChar))); + + if (fullPath == null + || !fullPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || !File.Exists(fullPath)) + { + results.Add(new CrudEndpointSeedApplyResult(normalized, 0, 0, 0, 0, "CRUD seed file not found.")); + continue; + } + + filePaths.Add(fullPath); + } + + results.AddRange(await ApplyAsync(tenantId, filePaths)); + return results; + } + + private async Task> ApplyAsync(Guid? tenantId, IReadOnlyCollection filePaths) + { + var results = new List(); + + using (currentTenant.Change(tenantId)) + { + foreach (var filePath in filePaths) + { + // Seed contributor'larin unit of work'u kapandigi icin her dosya kendi + // unit of work'unde islenir; ListFormSeedDataApplier ile ayni kural. + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + results.Add(await ApplyFileAsync(filePath)); + await uow.CompleteAsync(); + } + } + + return results; + } + + private async Task ApplyFileAsync(string filePath) + { + var fileName = Path.GetFileName(filePath); + try + { + var seedFile = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(filePath), JsonReadOptions); + + var entityName = string.IsNullOrWhiteSpace(seedFile?.EntityName) + ? Path.GetFileNameWithoutExtension(filePath) + : seedFile.EntityName.Trim(); + + if (seedFile?.Endpoints == null || seedFile.Endpoints.Count == 0) + { + logger.LogWarning("[{File}] No endpoint found, skipped.", fileName); + return new CrudEndpointSeedApplyResult(fileName, 0, 0, 0, 0); + } + + var existing = await repository.GetListAsync(x => x.EntityName == entityName); + var inserted = 0; + var updated = 0; + var skipped = 0; + + foreach (var item in seedFile.Endpoints) + { + if (string.IsNullOrWhiteSpace(item.OperationType)) + continue; + + var current = existing.FirstOrDefault(x => + string.Equals(x.OperationType, item.OperationType, StringComparison.OrdinalIgnoreCase)); + + if (current == null) + { + await repository.InsertAsync(new CrudEndpoint + { + EntityName = entityName, + Method = item.Method, + Path = item.Path, + OperationType = item.OperationType, + IsActive = item.IsActive + }, autoSave: true); + + inserted++; + continue; + } + + if (current.Method == item.Method && current.Path == item.Path && current.IsActive == item.IsActive) + { + skipped++; + continue; + } + + if (!IsFileNewerThan(seedFile.GeneratedAt, current)) + { + logger.LogWarning( + "[{File}] '{Entity}.{Operation}' differs from the seed file but the record is newer; kept as is.", + fileName, entityName, item.OperationType); + + skipped++; + continue; + } + + current.Method = item.Method; + current.Path = item.Path; + current.IsActive = item.IsActive; + await repository.UpdateAsync(current, autoSave: true); + updated++; + } + + var deleted = await DeleteObsoleteAsync(fileName, entityName, seedFile, existing); + + logger.LogInformation( + "[{File}] '{Entity}': {Inserted} inserted, {Updated} updated, {Deleted} deleted, {Skipped} kept.", + fileName, entityName, inserted, updated, deleted, skipped); + + return new CrudEndpointSeedApplyResult(fileName, inserted, updated, deleted, skipped); + } + catch (Exception ex) + { + // Tek bir dosyanin hatasi diger endpoint'lerin uygulanmasini engellemez. + logger.LogError(ex, "Failed to process CRUD seed file: {FileName}", fileName); + return new CrudEndpointSeedApplyResult(fileName, 0, 0, 0, 0, ex.Message); + } + } + + /// + /// Dosyada artik yer almayan operasyonlari kaldirir. Silme yalnizca dosya ilgili kayittan + /// yeniyse yapilir; aksi halde kayit runtime'da eklenmis olabilir ve migrate onu goturmez. + /// + private async Task DeleteObsoleteAsync( + string fileName, + string entityName, + CrudEndpointSeedFileDto seedFile, + List existing) + { + var seededOperations = seedFile.Endpoints + .Select(x => x.OperationType) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var obsolete = existing + .Where(x => !seededOperations.Contains(x.OperationType)) + .ToList(); + + if (obsolete.Count == 0) + return 0; + + var removable = obsolete.Where(x => IsFileNewerThan(seedFile.GeneratedAt, x)).ToList(); + + foreach (var kept in obsolete.Except(removable)) + { + logger.LogWarning( + "[{File}] '{Entity}.{Operation}' is not in the seed file but the record is newer; kept as is.", + fileName, entityName, kept.OperationType); + } + + if (removable.Count > 0) + await repository.DeleteManyAsync(removable, autoSave: true); + + return removable.Count; + } + + /// + /// Dosyanin kayittan yeni olup olmadigi. Damgasi olmayan (eski bicimde yazilmis) bir dosya + /// hicbir zaman yeni sayilmaz; kullanicinin runtime revizyonu boyle bir dosya yuzunden ezilmez. + /// + private static bool IsFileNewerThan(DateTime generatedAt, CrudEndpoint endpoint) + { + if (generatedAt == default) + return false; + + var recordTime = endpoint.LastModificationTime ?? endpoint.CreationTime; + + return generatedAt.ToUniversalTime() > recordTime.ToUniversalTime(); + } +} diff --git a/claude.md b/claude.md index 5bf20dfe..c7a6513b 100644 --- a/claude.md +++ b/claude.md @@ -188,10 +188,39 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → (Custom Components ekranının seed verisi); bileşenin kendi dosyası yoktur, her bileşen bu dosyada bir satırdır. Hem listeden hem Visual Designer'dan (`CustomComponentAppService`) yapılan kayıt aynı dosyaya yansır; kullandığı endpoint'ler `crud/{Entity}.json`. -- **Wizard dosyası** → tek başına taşınmaz; export zip'i `wizard/`, `data/`, `crud/` ve - `{sql|postgres}/{object|execute}/` bağımlılıklarını da içerir. `Custom` yolunda `data/` girdisi - yalnızca ilgili bileşen ve bağımlılık zincirine süzülür; import'ta hedef dosyanın üstüne - yazılmaz, satırlar anahtar üzerinden birleştirilir. +- **Wizard dosyası** → tek başına taşınmaz; export zip'i **özyinelemeli bağımlılık kapanışını** + taşır (`WizardExportCollector`): alt formların wizard dosyaları, ekranın `SeedFilePath` veri + aynası, lookup sorgularının işaret ettiği nesnelerin scriptleri, `crud/` ve + `{sql|postgres}/{object|execute}/` dosyaları. `Custom` yolunda `data/` girdisi yalnızca ilgili + bileşen ve bağımlılık zincirine süzülür; import'ta hedef dosyanın üstüne yazılmaz, satırlar + anahtar üzerinden birleştirilir. Kapanışa yeni bir bağımlılık türü eklenirse + `WizardImportValidator`'daki karşılığı da eklenir — biri toplar, diğeri eksikliğini yakalar. +- **Şema değişikliği** → ayrım **kayıp riskine** göredir. Eksik kolon **eklenir** (kayıpsız; + `ALTER TABLE ADD`), var olan kolon **hiçbir zaman değiştirilmez**, hiçbir kolon düşürülmez, + hiçbir tablo `DROP` edilmez. Ölçü farkında (uzunluk/kesinlik/nullability) import durdurulur ve + fark somut olarak bildirilir; varsayılanı olmayan `NOT NULL` bir kolonun dolu tabloya eklenmesi + de durdurulur — hangi değerin yazılacağı iş kararıdır. Karşılaştırma tek yerdedir + (`SqlTableSchemaComparer`), analiz ve kapanış aynı sonucu kullanır. + Karşılaştırmada tip **adı** kullanılmaz — sağlayıcı katalogları farklı kanonik ad raporlar ve + metin karşılaştırması sahte fark üretir. Genel kural: **sağlayıcıya göre değişen bir değeri + metin olarak karşılaştırma, katalogdan dinamik oku.** +- **Veritabanına dokunan her import ucu** → `EnsureImportNotBlocked` ile başlar. Analizdeki + engelleme kararı sunucuda `blocked` dosyasında durur; yeni bir uç eklenirse kontrolü de eklenir, + yoksa karar istemci tarafından atlanabilir hâle gelir. +- **Wizard import'u** → analiz dosyalar yazılmadan **önce** doğrular (`WizardImportValidator`); + `Error` seviyesinde bir bulgu varsa oturuma `blocked` işareti düşer ve `ApplyImport` reddeder. + Kapanış (`CompleteImport`) bağımlılık sırasıyla çalışır: `sql/object` → `sql/execute` → `crud` + → `data` → `wizard`. Tablo eksikse önce scripti çalışır; sıra değiştirilmez. Ekran kurulumu + wizard ekranıyla **aynı** yoldan geçer (`DeployWizardAsync`); dosyadaki `ListForm`/`Fields` + sözleşmesi olduğu gibi kullanılır, wizard cevaplarından yeniden türetilmez — türetmek tasarımcı + değişikliklerini silerdi. Var olan ekrana dokunulmaz, her ekran kendi UoW'unda kurulur. + Engelleyici bulgu iki sınıftır: **arşiv kaynaklı** (`RequiresReexport` — bozuk/eksik dosya, + karşılığı olmayan referans) hedef ortamda giderilemez, export tekrarlanır; **ortam kaynaklı** + (eksik tablo, şema farkı) ekrandan giderilir. Yeni bir `Error` eklerken hangisi olduğu işaretlenir. + Engelleme **oturum genelidir, dosya bazlı değildir** — + import ya bütün olarak çalışır ya da hiç. Bunun bedeli, her engelin ekran içinde çözülebilir + olmasıdır: tabloya dair bir bulgu üretirken `TableName` (+ tablo hedefte yoksa `TableMissing`) + doldurulur, istemci tablo tasarımcısını buna göre açar. - **`ListForm.SeedFilePath` dolu bir ekran** → `SeedSyncInsert/SeedSyncUpdate/SeedSyncDelete` bayraklarından işaretli olan işlemler kapsam klasöründeki `data/{ListFormCode}.json` dosyasına yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında ve wizard import'unun kapanışında @@ -199,6 +228,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → eklenir, varsa dokunulmaz; import'ta `overwriteExisting` açıktır: var olan kayıt dosyadaki değerlerle güncellenir ve soft delete izleri (`IsDeleted`/`DeletionTime`/`DeleterId`) temizlenir. Yeni bir kayıt yolu (yeni endpoint, toplu işlem, tipli AppService) eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar. +- **`crud/{Entity}.json`** → uygulama kuralı `CrudEndpointSeedApplier`'dadır ve damga + karşılaştırmalıdır: eksik endpoint eklenir, mevcut kayıt yalnızca dosyanın `GeneratedAt` değeri + kaydın `LastModificationTime`/`CreationTime` değerinden yeniyse güncellenir/silinir. Migrate + kullanıcının runtime revizyonunu ezmez. Yeni bir seeder yazarken bu kuralı taklit et: **migrate + varsayılanı eklemektir, ezmek değil.** - **`MenusData.json`** → `Routes` bölümüne dokunma; yalnızca `Modules` ve `Menus`. - **Çok değerli alan (`dxTagBox` / çoklu `dxGridBox`)** → tek metin kolonunda `|` ile saklanır. Ekran varsayılan `list-form-data/*` ucundan kaydediyorsa birleştirmeyi `QueryHelper` yapar; diff --git a/configs/seeds/host/data/App.DeveloperKit.CustomComponents.json b/configs/seeds/host/data/App.DeveloperKit.CustomComponents.json index 85e3eaba..17c72cbd 100644 --- a/configs/seeds/host/data/App.DeveloperKit.CustomComponents.json +++ b/configs/seeds/host/data/App.DeveloperKit.CustomComponents.json @@ -1,7 +1,7 @@ { "ListFormCode": "App.DeveloperKit.CustomComponents", "KeyFieldName": "Id", - "GeneratedAt": "2026-09-05T18:02:07.557592Z", + "GeneratedAt": "2026-09-07T14:42:42.8639631Z", "Order": 0, "Rows": [ { diff --git a/ui/src/components/shared/DbMigrateLogPanel.tsx b/ui/src/components/shared/DbMigrateLogPanel.tsx index c190fdd2..031c372d 100644 --- a/ui/src/components/shared/DbMigrateLogPanel.tsx +++ b/ui/src/components/shared/DbMigrateLogPanel.tsx @@ -46,7 +46,7 @@ function DbMigrateLogPanel({ onClose }: DbMigrateLogPanelProps) { return (
-
+
DB Migration Logs {/* diff --git a/ui/src/proxy/admin/wizard/models.ts b/ui/src/proxy/admin/wizard/models.ts index 126c0f52..85959322 100644 --- a/ui/src/proxy/admin/wizard/models.ts +++ b/ui/src/proxy/admin/wizard/models.ts @@ -211,14 +211,70 @@ export interface WizardImportAnalysisDto { scopeFolder: string entries: WizardImportEntryDto[] warnings: string[] + /** Zip icerigi ile hedef ortam karsilastirildiginda bulunan tutarsizliklar. */ + issues: WizardImportIssueDto[] + /** Engelleyici bulgu varsa import baslatilamaz; sunucu da yazma ucunu reddeder. */ + hasBlockingIssues: boolean + /** Sema islerinin (script, kolon ekleme, tablo tasarimcisi) hedef aldigi veri kaynagi. */ + dataSourceCode: string +} + +/** Import analizinde bulunan bir tutarsizligin agirligi; backend enum'u ile birebir. */ +export enum WizardImportIssueSeverityEnum { + Error = 0, + Warning = 1, +} + +/** Import analizinde bulunan tek bir tutarsizlik. */ +export interface WizardImportIssueDto { + severity: WizardImportIssueSeverityEnum + /** Bulgunun ilgili oldugu kapsam-goreli yol; genel bir bulguda bostur. */ + path?: string + message: string + /** + * Bulgunun karsiligi olan SQL cumlesi. Eksik kolon uyarilarinda kapanista calistirilacak + * cumledir; engelleyici bulgularda kullanicinin elle uygulayacagi cumledir. + */ + statement?: string + /** Bulgu bir tablo semasiyla ilgiliyse tablonun adi; tablo tasarimcisi bununla acilir. */ + tableName?: string + /** Tablo semasi; script'te sema oneki yoksa bostur. */ + schemaName?: string + /** Tablo hedefte hic yoksa true; tasarimci olusturma modunda acilir. */ + tableMissing?: boolean + /** + * Bulgu arsivin kendi eksikliginden/bozulmasindan kaynaklaniyorsa true. Hedef ortamda + * giderilemez; tek cozum kaynak ortamda export'u tekrarlamaktir. + */ + requiresReexport?: boolean } /** - * Import kapanisinin sonucu. Yazilan `data/*.json` dosyalari ayni islemde veritabanina da - * uygulanir; her dosyanin sonucu burada raporlanir. + * Import kapanisinin sonucu. Yazilan dosyalar bagimlilik sirasiyla veritabanina uygulanir: + * once SQL nesneleri, sonra CRUD tanimlari, en son veri satirlari. */ export interface WizardImportCompleteResultDto { + /** Calistirilan SQL nesne scriptleri; veri satirlarindan once uygulanir. */ + appliedSql: WizardImportStepResultDto[] + /** + * Hedefte eksik olup eklenen kolonlar. Script'in CREATE blogu var olan bir tabloda calismadigi + * icin yeni kolonlar buradan gelir; var olan bir kolon degistirilmez, hicbir kolon dusurulmez. + */ + appliedSchema: WizardImportStepResultDto[] + /** Veritabanina uygulanan CRUD endpoint dosyalari. */ + appliedCrud: WizardImportStepResultDto[] appliedData: WizardImportSeedApplyResultDto[] + /** Kurulan ekran tanimlari; import bitince ekran calisir, migrate beklemez. */ + appliedWizards: WizardImportStepResultDto[] +} + +/** Import kapanisindaki tek bir adimin sonucu (SQL scripti ya da CRUD dosyasi). */ +export interface WizardImportStepResultDto { + path: string + /** Adim basariliysa yapilan isi ozetleyen kisa metin. */ + detail?: string + /** Adim basarisizsa nedeni. */ + error?: string } /** Tek bir veri seed dosyasinin veritabanina uygulanma sonucu. */ @@ -240,3 +296,21 @@ export interface WizardImportApplyResultDto { overwritten: boolean size: number } + +/** Export zip'ine girecek tek bir seed dosyasi. */ +export interface WizardExportEntryDto { + /** Kapsam klasorune goreli yol, ornegin `sql/object/Prj_T_Task.sql`. */ + path: string + /** Dosyanin arsive neden girdigi (wizard'in kendisi, alt form, lookup, veri...). */ + reason: string +} + +/** Indirmeden once gosterilen export onizlemesi. */ +export interface WizardExportAnalysisDto { + fileName: string + archiveName: string + scopeFolder: string + entries: WizardExportEntryDto[] + /** Bagimlilik olarak bulunan ama diskte karsiligi olmayan dosyalar. */ + warnings: string[] +} diff --git a/ui/src/services/wizard.service.ts b/ui/src/services/wizard.service.ts index 6951fa22..fba9259b 100644 --- a/ui/src/services/wizard.service.ts +++ b/ui/src/services/wizard.service.ts @@ -1,5 +1,6 @@ import { ListFormWizardDto, + WizardExportAnalysisDto, WizardFileInfoDto, WizardImportAnalysisDto, WizardImportApplyResultDto, @@ -44,8 +45,20 @@ export const deleteWizardFile = (fileName: string) => }) /** - * Wizard dosyasini ve bagli seed dosyalarini (data/crud/sql) tek bir zip olarak indirir. - * Zip icindeki klasor duzeni seed kapsam klasoru ile aynidir; baska bir ortamda dogrudan acilabilir. + * Indirmeden once arsivin tam icerigini ve cozulemeyen bagimliliklarini getirir; export ile + * ayni toplamayi kullanir, dolayisiyla onizlemedeki liste indirilen zip ile birebir aynidir. + */ +export const getWizardExportAnalysis = (fileName: string) => + apiService.fetchData({ + method: 'GET', + url: `/api/app/list-form-wizard/export-analysis`, + params: { fileName }, + }) + +/** + * Wizard dosyasini ve bagimlilik kapanisindaki tum seed dosyalarini (data/crud/sql, alt formlar) + * tek bir zip olarak indirir. Zip icindeki klasor duzeni seed kapsam klasoru ile aynidir; baska bir + * ortamda dogrudan acilabilir. */ export const exportWizardFile = (fileName: string) => apiService.fetchData({ diff --git a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx index 7f3ee851..600ea096 100644 --- a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx @@ -28,6 +28,7 @@ import { deleteWizardFile, downloadBlob, exportWizardFile, + getWizardExportAnalysis, getWizardFiles, } from '@/services/wizard.service' import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon' @@ -35,7 +36,11 @@ import { useLocalization } from '@/utils/hooks/useLocalization' import { useStoreActions, useStoreState } from '@/store/store' import type { ListViewLayoutType } from '../edit/types' import { ROUTES_ENUM } from '@/routes/route.constant' -import { WizardComponentKindEnum, WizardFileInfoDto } from '@/proxy/admin/wizard/models' +import { + WizardComponentKindEnum, + WizardExportAnalysisDto, + WizardFileInfoDto, +} from '@/proxy/admin/wizard/models' import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config' import { usePermission } from '@/utils/hooks/usePermission' import { WIZARD_PERMISSION } from '@/constants/permission.constant' @@ -61,6 +66,11 @@ const WizardFileManager = () => { const [loading, setLoading] = useState(false) const [deletingFile, setDeletingFile] = useState(null) const [exportingFile, setExportingFile] = useState(null) + // Eksik bagimlilik bulundugunda indirmeden once gosterilen onizleme. + const [exportPreview, setExportPreview] = useState<{ + file: WizardFileInfoDto + analysis: WizardExportAnalysisDto + } | null>(null) const [confirm, setConfirm] = useState(null) const [search, setSearch] = useState('') const [importFile, setImportFile] = useState(null) @@ -114,14 +124,51 @@ const WizardFileManager = () => { // Zip'i sunucu uretir; istemci yalnizca indirmeyi tetikler. Dosya adi Content-Disposition // basligindan okunur, okunamazsa wizard adina duser. + /** Arsivi indirir; dosya adi sunucunun verdigi addir, yoksa wizard adina duser. */ + const downloadExport = async (file: WizardFileInfoDto) => { + const res = await exportWizardFile(file.fileName) + const disposition = String(res.headers?.['content-disposition'] ?? '') + const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition) + const fallback = `${file.wizardName || file.fileName.replace(/\.json$/i, '')}.zip` + downloadBlob(res.data, match ? decodeURIComponent(match[1]) : fallback) + } + + /** + * Once arsivin icerigi hesaplanir. Cozulemeyen bir bagimlilik varsa indirme dogrudan yapilmaz: + * eksik bir arsiv baska bir ortamda yarim kurulum uretir, bu yuzden kullaniciya once ne + * eksildigi gosterilir. + */ const handleExport = async (file: WizardFileInfoDto) => { setExportingFile(file.fileName) try { - const res = await exportWizardFile(file.fileName) - const disposition = String(res.headers?.['content-disposition'] ?? '') - const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition) - const fallback = `${file.wizardName || file.fileName.replace(/\.json$/i, '')}.zip` - downloadBlob(res.data, match ? decodeURIComponent(match[1]) : fallback) + const analysis = await getWizardExportAnalysis(file.fileName) + if (analysis.data.warnings.length > 0) { + setExportPreview({ file, analysis: analysis.data }) + return + } + + await downloadExport(file) + } catch (err: any) { + toast.push( + + {translate('::App.ListForm.WizardFileExportError')}: {err?.message ?? 'Unknown error'} + , + { placement: 'bottom-end' }, + ) + } finally { + setExportingFile(null) + } + } + + /** Onizlemedeki eksiklere ragmen indirmeyi surdurur. */ + const handleExportAnyway = async () => { + if (!exportPreview) return + + const { file } = exportPreview + setExportPreview(null) + setExportingFile(file.fileName) + try { + await downloadExport(file) } catch (err: any) { toast.push( @@ -552,6 +599,55 @@ const WizardFileManager = () => { + + {/* Eksik bagimlilikli export onizlemesi */} + setExportPreview(null)} + onRequestClose={() => setExportPreview(null)} + > + +
+ +
+

+ {translate('::App.ListForm.WizardExportIncomplete')} +

+
    + {exportPreview?.analysis.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+

+ {translate('::App.ListForm.WizardExportContents')} ( + {exportPreview?.analysis.entries.length}) +

+
    + {exportPreview?.analysis.entries.map((entry) => ( +
  • + + {entry.path} + + {entry.reason} +
  • + ))} +
+
+
+
+ + + + +
) } diff --git a/ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx b/ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx index 1fbd9af6..c88e53c7 100644 --- a/ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx @@ -2,15 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import classNames from 'classnames' import { DiffEditor } from '@monaco-editor/react' -import { Button, Dialog, Progress } from '@/components/ui' +import { Button, Dialog, Progress, Steps } from '@/components/ui' import { FaCheck, FaCheckCircle, FaCodeBranch, + FaCopy, FaExclamationTriangle, FaFileAlt, FaFileImport, FaPlusCircle, + FaTable, FaTimes, FaTimesCircle, FaUndo, @@ -22,14 +24,18 @@ import { rollbackWizardImport, } from '@/services/wizard.service' import { useLocalization } from '@/utils/hooks/useLocalization' -import { useStoreState } from '@/store/store' +import { useStoreActions, useStoreState } from '@/store/store' import Input from '@/components/ui/Input' import { useDialogContext } from '@/components/ui/Dialog/Dialog' +import SqlTableDesignerDialog from '@/views/developerKit/SqlTableDesignerDialog' import { WizardImportAnalysisDto, WizardImportEntryDto, WizardImportEntryStatusEnum, + WizardImportIssueDto, + WizardImportIssueSeverityEnum, WizardImportSeedApplyResultDto, + WizardImportStepResultDto, } from '@/proxy/admin/wizard/models' interface WizardImportDialogProps { @@ -49,7 +55,19 @@ interface EntryDecision { content?: string } -type ImportPhase = 'analyzing' | 'review' | 'running' | 'finished' | 'rolledBack' +/** + * Dialogun durumu. Adim gezinmesi `step` ile yapilir; bu tur yalnizca uygulamanin nerede oldugunu + * soyler — `ready` iken kullanici adimlar arasinda serbestce dolasir, `running` sonrasi adim sabitlenir. + */ +type ImportPhase = 'analyzing' | 'ready' | 'running' | 'finished' | 'rolledBack' + +/** Dialog adimlari; sira bagimlilik sirasidir, geriye donulebilir ama ileri atlanmaz. */ +const STEP = { + files: 0, + checks: 1, + plan: 2, + apply: 3, +} as const interface AppliedRow { path: string @@ -102,9 +120,18 @@ const ImportDialogBody = ({ children }: { children: ReactNode }) => { */ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogProps) => { const { translate } = useLocalization() + const { getConfig } = useStoreActions((a) => a.abpConfig) const mode = useStoreState((state) => state.theme.mode) const [phase, setPhase] = useState('analyzing') + const [step, setStep] = useState(STEP.files) + // Bulgudan acilan tablo tasarimcisi; kapaninca analiz yenilenir. `missing` ise tablo hedefte hic + // yoktur ve tasarimci olusturma modunda acilir. + const [designTable, setDesignTable] = useState<{ + schemaName: string + tableName: string + missing: boolean + } | null>(null) const [analysis, setAnalysis] = useState(null) const [error, setError] = useState(null) const [decisions, setDecisions] = useState>({}) @@ -112,6 +139,13 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro const [applied, setApplied] = useState([]) // Yazilan data/*.json dosyalarinin veritabanina uygulanma sonucu; oturum kapanisinda doner. const [seedApplied, setSeedApplied] = useState([]) + // Kapanistaki bagimlilik sirasinin ilk iki adimi: SQL nesneleri ve CRUD tanimlari. + const [sqlApplied, setSqlApplied] = useState([]) + const [crudApplied, setCrudApplied] = useState([]) + // Hedefte eksik olup kapanista eklenen kolonlar. + const [schemaApplied, setSchemaApplied] = useState([]) + // Kurulan ekran tanimlari; import bitince ekran calisir durumdadir, migrate beklemez. + const [wizardsApplied, setWizardsApplied] = useState([]) const [currentPath, setCurrentPath] = useState(null) const [showIdentical, setShowIdentical] = useState(false) const [editorEpoch, setEditorEpoch] = useState(0) @@ -158,18 +192,66 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro [entries, selectedPath], ) - // Analiz: zip sunucuda gecici bir oturuma acilir, hedef klasore henuz dokunulmaz. - useEffect(() => { - let cancelled = false + /** + * Kapanışta veritabanına gerçekten uygulanacak girdiler. Sunucu kuralıyla aynı: yazılan dosyalar + * ve hedefle **zaten aynı** olanlar uygulanır — ikincisinde yazacak bir şey yoktur ama + * veritabanındaki karşılığı eksik olabilir. Kullanıcının "mevcudu koru" dediği dosya dışarıdadır. + */ + const effectiveEntries = useMemo( + () => + entries.filter( + (e) => e.status === WizardImportEntryStatusEnum.Identical || decisions[e.path]?.apply, + ), + [entries, decisions], + ) - const run = async () => { + const effectiveByFolder = useCallback( + (...folders: string[]) => + effectiveEntries.filter((e) => folders.includes(e.folder.toLowerCase())), + [effectiveEntries], + ) + + const blockingIssues = useMemo( + () => + (analysis?.issues ?? []).filter((i) => i.severity === WizardImportIssueSeverityEnum.Error), + [analysis], + ) + const advisoryIssues = useMemo( + () => + (analysis?.issues ?? []).filter((i) => i.severity === WizardImportIssueSeverityEnum.Warning), + [analysis], + ) + + // Engelleyici bulgular iki gruba ayrilir: arsivin kendi eksikligi (burada giderilemez, export + // tekrarlanmali) ve hedef ortamin eksikligi (tablo tasarimcisiyla bu ekrandan giderilir). + const reexportIssues = useMemo( + () => blockingIssues.filter((i) => i.requiresReexport), + [blockingIssues], + ) + const repairableIssues = useMemo( + () => blockingIssues.filter((i) => !i.requiresReexport), + [blockingIssues], + ) + + /** + * Zip'i sunucuda gecici bir oturuma acar ve hedefle karsilastirir; hedef klasore dokunmaz. + * Kullanici sema farkini tablo tasarimcisindan giderdiginde de yeniden calisir — o durumda + * onceki oturum birakilir, cunku bulgular artik bayattir. + */ + const runAnalysis = useCallback( + async (previousImportId?: string) => { if (!file) return + setPhase('analyzing') setError(null) + setSelectedPath(null) + + if (previousImportId) { + await rollbackWizardImport(previousImportId).catch(() => undefined) + } + try { const res = await analyzeWizardImport(file) - if (cancelled) return - const data = res.data const initial: Record = {} data.entries.forEach((entry) => { @@ -186,24 +268,24 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro setAnalysis(data) setDecisions(initial) - setPhase('review') + setPhase('ready') + setStep(STEP.files) const firstConflict = data.entries.find( (e) => e.status === WizardImportEntryStatusEnum.Conflict, ) if (firstConflict) setSelectedPath(firstConflict.path) } catch (err: any) { - if (cancelled) return setError(err?.response?.data?.error?.message ?? err?.message ?? 'Unknown error') - setPhase('review') + setPhase('ready') } - } + }, + [file], + ) - run() - return () => { - cancelled = true - } - }, [file]) + useEffect(() => { + runAnalysis() + }, [runAnalysis]) // Diff editorunun tasidigi taslak, secim degistikce ilgili dosyanin son haline doner. useEffect(() => { @@ -255,8 +337,13 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro if (!analysis) return setPhase('running') + setStep(STEP.apply) setApplied([]) setSeedApplied([]) + setSqlApplied([]) + setSchemaApplied([]) + setCrudApplied([]) + setWizardsApplied([]) setError(null) const rows: AppliedRow[] = [] @@ -297,7 +384,16 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro setCurrentPath(null) try { const res = await completeWizardImport(analysis.importId) + setSqlApplied(res.data.appliedSql ?? []) + setSchemaApplied(res.data.appliedSchema ?? []) + setCrudApplied(res.data.appliedCrud ?? []) setSeedApplied(res.data.appliedData ?? []) + const installed = res.data.appliedWizards ?? [] + setWizardsApplied(installed) + + // Kurulan ekranin menusu ve yetkileri ABP config'inden gelir; yenilenmezse kullanici + // ekrani ancak sayfayi bastan yukledikten sonra gorurdu. + if (installed.some((row) => !row.error)) await getConfig(true) } catch (err: any) { // Dosyalar hedefe yazildi; yalnizca veritabanina uygulama ya da temizlik basarisiz oldu. setError(err?.response?.data?.error?.message ?? err?.message ?? null) @@ -309,7 +405,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro /** Import baslamadan kapatilirsa sunucudaki staging klasoru birakilmaz. */ const handleClose = () => { if (phase === 'running') return - if (analysis && phase === 'review') { + if (analysis && phase === 'ready') { rollbackWizardImport(analysis.importId).catch(() => undefined) } onClose() @@ -361,6 +457,128 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro ) } + /** + * Tek bir bulgu. `statement` dolu olduğunda cümle kopyalanabilir bir blokta gösterilir; şema + * bulgularında ayrıca tablo tasarımcısı açılabilir. Bir kolon eklemek kayıpsız olduğu için + * kapanışta kendiliğinden uygulanır, var olan bir kolonu değiştirmek kullanıcının kararıdır — + * mesaj hangisinin geçerli olduğunu söyler. + */ + const renderIssue = (issue: WizardImportIssueDto, key: string) => ( +
  • + {issue.path && {issue.path}: } + {issue.message} + {(issue.statement || issue.tableName) && ( +
    + {issue.statement && ( +
    +              {issue.statement}
    +            
    + )} + {issue.statement && ( + + )} +
    + )} +
  • + ) + + /** Zip acilirken atlanan girdiler; bulgularla ayni adimda gosterilir. */ + const renderZipWarnings = () => + analysis && analysis.warnings.length > 0 ? ( +
    +
    + + {translate('::App.ListForm.WizardImportWarnings')} ({analysis.warnings.length}) +
    +
      + {analysis.warnings.map((warning) => ( +
    • {warning}
    • + ))} +
    +
    + ) : null + + const renderIssues = () => { + const hasZipWarnings = (analysis?.warnings.length ?? 0) > 0 + + if (blockingIssues.length === 0 && advisoryIssues.length === 0 && !hasZipWarnings) { + return ( +
    + + {translate('::App.ListForm.WizardImportNoIssues')} +
    + ) + } + + return ( +
    + {renderZipWarnings()} + {reexportIssues.length > 0 && ( +
    +
    + + {translate('::App.ListForm.WizardImportReexportIssues')} ({reexportIssues.length}) +
    +
    {translate('::App.ListForm.WizardImportReexportHint')}
    +
      + {reexportIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))} +
    +
    + )} + {repairableIssues.length > 0 && ( +
    +
    + + {translate('::App.ListForm.WizardImportBlockingIssues')} ({repairableIssues.length}) +
    +
      + {repairableIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))} +
    +
    + )} + {advisoryIssues.length > 0 && ( +
    +
    + + {translate('::App.ListForm.WizardImportAdvisoryIssues')} ({advisoryIssues.length}) +
    +
      + {advisoryIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))} +
    +
    + )} +
    + ) + } + const renderReview = () => (
    {/* Dosya listesi */} @@ -524,25 +742,82 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro ) : ( {translate('::App.ListForm.WizardImportReady')} )} - {analysis && analysis.warnings.length > 0 && ( -
    -
    - - {translate('::App.ListForm.WizardImportWarnings')} -
    -
      - {analysis.warnings.map((warning) => ( -
    • {warning}
    • - ))} -
    -
    - )}
    )}
    ) + /** Plan bölümü; boş bölüm hiç çizilmez, kullanıcı yalnızca gerçekten olacakları görür. */ + const renderPlanSection = (title: string, items: string[], hint?: string) => { + if (items.length === 0) return null + + return ( +
    +
    + + {title} + ({items.length}) +
    + {hint &&
    {hint}
    } +
      + {items.map((item) => ( +
    • + {item} +
    • + ))} +
    +
    + ) + } + + /** + * Onaydan önceki son adım: onaylandığında ne olacağının tam listesi. Sıra, sunucudaki kapanış + * sırasıyla aynıdır — dosya yazımı, SQL nesneleri, eksik kolonlar, CRUD, veri; ekran tanımları + * bu adımda uygulanmaz ve ayrıca belirtilir. + */ + const renderPlan = () => { + const files = applyList.map((e) => e.path) + const sql = effectiveByFolder('sql', 'postgres').map((e) => e.path) + const columns = advisoryIssues + .filter((i) => i.statement && i.tableName) + .map((i) => i.statement as string) + const crud = effectiveByFolder('crud').map((e) => e.path) + const data = effectiveByFolder('data').map((e) => e.path) + const wizards = effectiveByFolder('wizard').map((e) => e.path) + + const isEmpty = + files.length === 0 && + sql.length === 0 && + columns.length === 0 && + crud.length === 0 && + data.length === 0 && + wizards.length === 0 + + return ( +
    +
    + {translate('::App.ListForm.WizardImportPlanTitle')} +
    + {isEmpty && ( +
    + {translate('::App.ListForm.WizardImportPlanNothing')} +
    + )} + {renderPlanSection( + `1. ${translate('::App.ListForm.WizardImportPlanFiles')}`, + files, + analysis?.scopeFolder, + )} + {renderPlanSection(`2. ${translate('::App.ListForm.WizardImportPlanSql')}`, sql)} + {renderPlanSection(`3. ${translate('::App.ListForm.WizardImportPlanColumns')}`, columns)} + {renderPlanSection(`4. ${translate('::App.ListForm.WizardImportPlanCrud')}`, crud)} + {renderPlanSection(`5. ${translate('::App.ListForm.WizardImportPlanData')}`, data)} + {renderPlanSection(`6. ${translate('::App.ListForm.WizardImportPlanWizards')}`, wizards)} +
    + ) + } + const renderProgress = () => (
    {translate('::App.ListForm.WizardImportSuccess')}
    + {sqlApplied.length > 0 && ( +
    +
    {translate('::App.ListForm.WizardImportSqlApplied')}
    +
      + {sqlApplied.map((row) => ( +
    • + {row.path} + {row.error ?? row.detail} +
    • + ))} +
    +
    + )} + {schemaApplied.length > 0 && ( +
    +
    + {translate('::App.ListForm.WizardImportSchemaApplied')} +
    +
      + {schemaApplied.map((row) => ( +
    • + {row.path} + {row.error ?? row.detail} +
    • + ))} +
    +
    + )} + {crudApplied.length > 0 && ( +
    +
    + {translate('::App.ListForm.WizardImportCrudApplied')} +
    +
      + {crudApplied.map((row) => ( +
    • + {row.path} + {row.error ?? row.detail} +
    • + ))} +
    +
    + )} {seedApplied.length > 0 && (
    @@ -618,7 +936,21 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
    )} -
    {translate('::App.ListForm.WizardImportRunMigrateHint')}
    + {wizardsApplied.length > 0 && ( +
    +
    + {translate('::App.ListForm.WizardImportWizardsApplied')} +
    +
      + {wizardsApplied.map((row) => ( +
    • + {row.path} + {row.error ?? row.detail} +
    • + ))} +
    +
    + )} {error && (
    @@ -632,13 +964,30 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
    ) - return ( + const isBusy = phase === 'running' + const isDone = phase === 'finished' || phase === 'rolledBack' + + // Adimlar bagimlilik sirasindadir: catisma cozulmeden kontrole, engelleyici bulgu giderilmeden + // plana gecilmez. Geriye donus her zaman serbesttir. + const canLeaveFiles = unresolvedCount === 0 + const canLeaveChecks = blockingIssues.length === 0 + + const canGoTo = (target: number) => { + if (isBusy || isDone || error) return false + if (target <= step) return true + if (target > STEP.files && !canLeaveFiles) return false + if (target > STEP.checks && !canLeaveChecks) return false + + return target <= STEP.plan + } + + const dialog = ( @@ -647,7 +996,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
    {translate('::App.ListForm.WizardImportTitle')}
    {file?.name} - {phase === 'review' && analysis && ( + {phase === 'ready' && analysis && (
    {entries.filter((e) => e.status === WizardImportEntryStatusEnum.New).length}{' '} @@ -664,6 +1013,21 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro )}
    + {!error && ( + { + if (canGoTo(index)) setStep(index) + }} + > + + + + + + )} + {phase === 'analyzing' && (
    @@ -672,54 +1036,103 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
    )} - {phase === 'review' && error && ( + {phase !== 'analyzing' && error && (
    {error}
    )} - {phase === 'review' && !error && analysis && renderReview()} + {phase === 'ready' && !error && analysis && ( + <> + {step === STEP.files && renderReview()} + {step === STEP.checks && renderIssues()} + {step === STEP.plan && renderPlan()} + + )} - {(phase === 'running' || phase === 'finished' || phase === 'rolledBack') && - renderProgress()} + {(isBusy || isDone) && renderProgress()} - {phase === 'review' && !error && unresolvedCount > 0 && ( + {phase === 'ready' && !error && step === STEP.files && unresolvedCount > 0 && ( {unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')} )} + {phase === 'ready' && !error && step === STEP.checks && blockingIssues.length > 0 && ( + + {translate('::App.ListForm.WizardImportBlocked')} + + )} + - {phase === 'review' && !error && ( + + {phase === 'ready' && !error && step > STEP.files && ( + + )} + + {phase === 'ready' && !error && step < STEP.plan && ( + + )} + + {phase === 'ready' && !error && step === STEP.plan && ( )}
    ) + + return ( + <> + {dialog} + + {/* Sema farkini gidermek icin mevcut tablo tasarimcisi acilir; deploy sonrasi analiz + yenilenir. Import dialogunun icine degil kardesi olarak cizilir: ic ice dialog odak ve + katman sirasini bozar. */} + {designTable && analysis && ( + setDesignTable(null)} + onDeployed={async () => { + setDesignTable(null) + await runAnalysis(analysis.importId) + }} + /> + )} + + ) } export default WizardImportDialog diff --git a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx index 57b3792c..5db26d4e 100644 --- a/ui/src/views/developerKit/SqlTableDesignerDialog.tsx +++ b/ui/src/views/developerKit/SqlTableDesignerDialog.tsx @@ -89,6 +89,11 @@ interface TableDesignerDialogProps { dataSource: string | null onDeployed?: (table: { schemaName: string; tableName: string }) => void | Promise initialTableData?: { schemaName: string; tableName: string } | null + /** + * Henuz var olmayan bir tablonun adi. Olusturma modu korunur; yalnizca ad, modul ve entity + * alanlari on doldurulur. `initialTableData` verildiginde yok sayilir. + */ + initialTableName?: string | null } type IndexType = 'PrimaryKey' | 'UniqueKey' | 'Index' @@ -1297,6 +1302,7 @@ const SqlTableDesignerDialog = ({ dataSource, onDeployed, initialTableData, + initialTableName, }: TableDesignerDialogProps) => { const { translate } = useLocalization() const lastCreateTableScript = useStoreState( @@ -1403,16 +1409,29 @@ const SqlTableDesignerDialog = ({ useEffect(() => { if (!isOpen) return + const knownTableName = initialTableData?.tableName ?? initialTableName reloadMenus((items) => { - // In edit mode, auto-select the matching menu code by moduleId - if (initialTableData) { - const parts = initialTableData.tableName.split('_') - const derivedModuleId = parts[0] ?? '' + // Adi bilinen tabloda (duzenleme ya da eksik tablo onarimi) modul menusu kendiliginden secilir. + if (knownTableName) { + const derivedModuleId = knownTableName.split('_')[0] ?? '' const match = items.find((m) => m.moduleId === derivedModuleId) if (match?.code) setSelectedMenuCode(match.code) } }) + // Eksik tablo onarimi: tablo hedefte yok, olusturma modu korunur; yalnizca ad alanlari dolar. + if (!initialTableData && initialTableName) { + const parts = initialTableName.split('_') + setSettings((s) => ({ + ...s, + tableName: initialTableName, + menuValue: parts[0] ?? '', + menuPrefix: parts[0] ?? '', + entityName: parts[parts.length - 1] ?? initialTableName, + displayName: parts[parts.length - 1] ?? initialTableName, + })) + } + if (dataSource) { sqlObjectManagerService .getAllObjects(dataSource) @@ -1560,7 +1579,7 @@ const SqlTableDesignerDialog = ({ .catch(() => {}) .finally(() => setIndexesLoading(false)) } - }, [isOpen, dataSource, initialTableData, canManageCrudEndpoints]) + }, [isOpen, dataSource, initialTableData, initialTableName, canManageCrudEndpoints]) const generatedSql = useMemo( () =>