Wizard Import ve Export işlemi mükemmelleştirildi.

This commit is contained in:
Sedat ÖZTÜRK 2026-09-07 17:43:46 +03:00
parent 9b9ef9c100
commit ad00e75911
23 changed files with 3857 additions and 523 deletions

View file

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

View file

@ -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`ı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.99.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`.

127
README.md
View file

@ -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`ı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`ı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
ı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}/

View file

@ -14,9 +14,15 @@ public interface IListFormWizardAppService
/// <summary>Izin gruplarini iki dilli gorunen adlariyla dondurur.</summary>
Task<List<WizardPermissionGroupDto>> GetPermissionGroups();
/// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary>
/// <summary>Wizard dosyasini ve bagimlilik kapanisindaki tum seed dosyalarini zip olarak dondurur.</summary>
Task<IRemoteStreamContent> ExportFile(string fileName);
/// <summary>
/// Indirmeden once arsivin icerigini ve cozulemeyen bagimliliklarini dondurur; eksik bir
/// arsivin fark edilmeden baska bir ortama tasinmasini engeller.
/// </summary>
Task<WizardExportAnalysisDto> ExportAnalysis(string fileName);
/// <summary>
/// Export zip'ini staging klasorune acar ve her dosya icin hedefteki karsiligiyla
/// karsilastirma sonucunu dondurur. Bu adim hicbir hedef dosyaya dokunmaz.

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
namespace Sozsoft.Platform.ListForms;
/// <summary>Export zip'ine girecek tek bir seed dosyasi.</summary>
public class WizardExportEntryDto
{
/// <summary>Kapsam klasorune goreli yol, ornegin <c>sql/object/Prj_T_Task.sql</c>.</summary>
public string Path { get; set; }
/// <summary>Dosyanin arsive neden girdigi (wizard'in kendisi, alt form, lookup, veri...).</summary>
public string Reason { get; set; }
}
/// <summary>
/// Indirmeden once gosterilen export onizlemesi. Zip'in tam icerigini ve cozulemeyen
/// bagimliliklari tasir; boylece eksik bir arsiv fark edilmeden baska bir ortama tasinmaz.
/// </summary>
public class WizardExportAnalysisDto
{
/// <summary>Kaynak wizard seed dosyasinin adi.</summary>
public string FileName { get; set; }
/// <summary>Indirilecek arsivin adi.</summary>
public string ArchiveName { get; set; }
/// <summary>Kapsam klasoru (<c>host</c> ya da <c>tenants/{tenantId}</c>).</summary>
public string ScopeFolder { get; set; }
public List<WizardExportEntryDto> Entries { get; set; } = [];
/// <summary>Bagimlilik olarak bulunan ama diskte karsiligi olmayan dosyalar.</summary>
public List<string> Warnings { get; set; } = [];
}

View file

@ -43,6 +43,66 @@ public class WizardImportAnalysisDto
/// <summary>Guvenlik/format nedeniyle atlanan zip girdileri icin aciklamalar.</summary>
public List<string> Warnings { get; set; } = [];
/// <summary>
/// Zip icerigi ile hedef ortam karsilastirildiginda bulunan tutarsizliklar. En az bir
/// <see cref="WizardImportIssueSeverityEnum.Error"/> varken dosya yazma adimi reddedilir.
/// </summary>
public List<WizardImportIssueDto> Issues { get; set; } = [];
/// <summary>Import'u engelleyen bir bulgu var mi; istemci baslat dugmesini buna gore kapatir.</summary>
public bool HasBlockingIssues { get; set; }
/// <summary>
/// Sema islerinin (script calistirma, kolon ekleme, tablo tasarimcisi) hedef aldigi veri
/// kaynagi. Seed script'leri kapsam duzeyindedir ve her zaman bu kaynakta calisir.
/// </summary>
public string DataSourceCode { get; set; }
}
/// <summary>Import analizinde bulunan tek bir tutarsizlik.</summary>
public class WizardImportIssueDto
{
public WizardImportIssueSeverityEnum Severity { get; set; }
/// <summary>Bulgunun ilgili oldugu kapsam-goreli yol; genel bir bulguda bos kalir.</summary>
public string Path { get; set; }
/// <summary>Kullaniciya gosterilen aciklama.</summary>
public string Message { get; set; }
/// <summary>
/// Bulgunun karsiligi olan SQL cumlesi. Eksik kolon uyarilarinda kapanista <b>calistirilacak</b>
/// cumledir; engelleyici bulgularda kullanicinin elle uygulayacagi cumledir. Karsiligi olmayan
/// bulguda bostur.
/// </summary>
public string Statement { get; set; }
/// <summary>
/// Bulgu bir tablo semasiyla ilgiliyse tablonun adi; istemci tablo tasarimcisini bu tablo icin
/// acar. Diger bulgularda bostur.
/// </summary>
public string TableName { get; set; }
/// <summary>Tablo semasi; script'te sema oneki yoksa bostur.</summary>
public string SchemaName { get; set; }
/// <summary>
/// <see cref="TableName"/> 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.
/// </summary>
public bool TableMissing { get; set; }
/// <summary>
/// Bulgu arsivin <b>kendi icerigindeki</b> bir eksiklikten ya da bozulmadan kaynaklaniyorsa
/// true: bozuk JSON, eksik bolum, karsiligi olmayan bir alt form / bagimlilik / script.
/// <para>
/// 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.
/// </para>
/// </summary>
public bool RequiresReexport { get; set; }
}
/// <summary>Tek bir dosyanin hedefe yazilmasi istegi.</summary>
@ -77,7 +137,43 @@ public class WizardImportApplyResultDto
/// </summary>
public class WizardImportCompleteResultDto
{
/// <summary>
/// Calistirilan SQL nesne scriptleri. Veri satirlari yazilmadan once uygulanir; tablo
/// yoksa once olusturulur, aksi halde veri adimi eksik tablo yuzunden bastan duserdi.
/// </summary>
public List<WizardImportStepResultDto> AppliedSql { get; set; } = [];
/// <summary>
/// Hedefte eksik olup eklenen kolonlar. Script'in <c>CREATE</c> 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.
/// </summary>
public List<WizardImportStepResultDto> AppliedSchema { get; set; } = [];
/// <summary>Veritabanina uygulanan CRUD endpoint dosyalari.</summary>
public List<WizardImportStepResultDto> AppliedCrud { get; set; } = [];
public List<WizardImportSeedApplyResultDto> AppliedData { get; set; } = [];
/// <summary>
/// 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 <c>Detail</c> bildirir; import bitince ekran calisir durumdadir, migrate beklemez.
/// </summary>
public List<WizardImportStepResultDto> AppliedWizards { get; set; } = [];
}
/// <summary>Import kapanisindaki tek bir adimin sonucu (SQL scripti ya da CRUD dosyasi).</summary>
public class WizardImportStepResultDto
{
/// <summary>Kapsam klasorune goreli yol.</summary>
public string Path { get; set; }
/// <summary>Adim basariliysa yapilan isi ozetleyen kisa metin.</summary>
public string Detail { get; set; }
/// <summary>Adim basarisizsa nedeni; basarili durumda bostur.</summary>
public string Error { get; set; }
}
/// <summary>Tek bir veri seed dosyasinin veritabanina uygulanma sonucu.</summary>

View file

@ -0,0 +1,329 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Sozsoft.Platform.ListForms;
/// <summary>
/// Bir seed script'inde bildirilen kolon. <see cref="Definition"/> script'teki ham metindir; hazir
/// <c>ALTER TABLE ADD</c> cumlesi bundan uretilir, boylece DEFAULT ve COLLATE gibi ekler korunur.
/// <para>
/// Tip <b>adi</b> tasinmaz: karsilastirmaya girmez (bkz. <see cref="SqlLiveColumn"/>), yalnizca
/// parantezli olculeri okunur.
/// </para>
/// </summary>
public sealed record SqlColumnDeclaration(
string Name,
string Definition,
int? Length,
int? Precision,
int? Scale,
bool? IsNullable);
/// <summary>
/// Bir seed script'inden okunan tablo tanimi. <see cref="RawName"/> script'teki yaziliştir
/// (sema oneki ve tirnaklar dahil); uretilen ALTER cumlesi onu oldugu gibi kullanir.
/// <see cref="SchemaName"/> script'te sema oneki yoksa bostur.
/// </summary>
public sealed record SqlTableDeclaration(
string TableName,
string? SchemaName,
string RawName,
IReadOnlyList<SqlColumnDeclaration> Columns);
/// <summary>
/// Seed script'indeki <c>CREATE TABLE</c> bloklarindan kolon tanimlarini okur.
/// <para>
/// Amac tam bir SQL ayristiricisi olmak degil, hedef tablonun canli semasiyla karsilastirilabilecek
/// kadarini guvenle cikarmaktir. Cozulemeyen her sey <b>sessizce atlanir</b>: bilinmeyen bir sozdizimi
/// icin tahmin yurutulmez, cunku yanlis bir "fark" raporu import'u haksiz yere engeller.
/// </para>
/// </summary>
public static class SqlCreateTableParser
{
/// <summary>Kolon degil, tablo duzeyinde kisit tanimlayan girdiler; sema karsilastirmasina girmezler.</summary>
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+)?(?<name>(?:\[[^\]]+\]|""[^""]+""|[\w$]+)(?:\s*\.\s*(?:\[[^\]]+\]|""[^""]+""|[\w$]+))*)\s*\(",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Kolon adi ve ardindan gelen tanim.</summary>
private static readonly Regex ColumnPattern = new(
@"^\s*(?<name>\[[^\]]+\]|""[^""]+""|[\w$]+)\s+(?<definition>.+)$",
RegexOptions.Singleline | RegexOptions.Compiled);
/// <summary>
/// Tip metninden sonra gelebilecek kolon nitelikleri. Tip cok kelimeli olabildigi icin
/// (<c>DOUBLE PRECISION</c>, <c>CHARACTER VARYING</c>, <c>TIMESTAMP WITH TIME ZONE</c>) tip,
/// kelime sayisiyla degil bu anahtar kelimelerden biriyle sonlandirilir. Liste SQL dilbilgisidir,
/// veriye dair bir varsayim degildir.
/// </summary>
private static readonly HashSet<string> TypeTerminators = new(StringComparer.OrdinalIgnoreCase)
{
"NOT", "NULL", "CONSTRAINT", "DEFAULT", "IDENTITY", "PRIMARY", "UNIQUE", "CHECK",
"REFERENCES", "COLLATE", "GENERATED", "COMMENT", "SPARSE", "ROWGUIDCOL", "MASKED",
"FILESTREAM", "AS", "ENCRYPTED", "SERIAL",
};
/// <summary>Tip metnindeki uzunluk/kesinlik argumanlari.</summary>
private static readonly Regex TypeArgumentPattern = new(
@"\(\s*(?<a1>MAX|\d+)\s*(?:,\s*(?<a2>\d+)\s*)?\)\s*$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Script'teki ilk <c>CREATE TABLE</c> blogunu okur. Blok bulunamaz ya da cozulemezse null doner
/// ve cagiran sema karsilastirmasini atlar.
/// </summary>
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<SqlColumnDeclaration>();
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);
}
/// <summary>Nitelenmis tablo adini sema ve tablo parcalarina ayirir.</summary>
private static SqlTableDeclaration BuildDeclaration(string rawName, IReadOnlyList<SqlColumnDeclaration> 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, @"(?<!NOT\s{1,10})\bNULL\b", RegexOptions.IgnoreCase)
? true
: null;
// Kolon tanimi tek bir ifadedir; noktali virgul iceren bir metin ondan uretilen ALTER
// cumlesini ikiye bolerdi. Boyle bir tanim cozulemez sayilir ve karsilastirmaya girmez.
if (definition.Contains(';', StringComparison.Ordinal))
return null;
return new SqlColumnDeclaration(name, definition, length, precision, scale, isNullable);
}
/// <summary>
/// Kolon taniminin basindaki tip metnini dondurur: ilk nitelik anahtar kelimesine kadar olan
/// kisim, parantezli argumanlari dahil. Okunamazsa null doner ve olcu karsilastirmasi atlanir.
/// </summary>
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;
}
/// <summary>Acilis parantezinden baslayarak esleyen kapanisa kadar olan govdeyi dondurur.</summary>
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;
}
/// <summary>Govdeyi yalnizca en dis seviyedeki virgullerden boler.</summary>
private static IEnumerable<string> 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..];
}
/// <summary>Tirnakli/koseli parantezli bolumun son karakterinin dizinini dondurur.</summary>
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]*", " ");
}
}

View file

@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Sozsoft.Platform.ListForms;
/// <summary>
/// Hedef veritabanindaki bir kolonun katalogdan (<c>INFORMATION_SCHEMA.COLUMNS</c>) okunan hali.
/// <para>
/// Tip <b>adi</b> bilincli olarak tasinmaz: saglayici katalogu kendi kanonik adini raporlar
/// (PostgreSQL'de <c>VARCHAR</c> -> <c>character varying</c>, <c>TIMESTAMP</c> ->
/// <c>timestamp without time zone</c>), 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.
/// </para>
/// </summary>
public sealed record SqlLiveColumn(string Name, int? Length, int? Precision, int? Scale, bool IsNullable);
/// <summary>
/// Hedefte olmayan, eklenmesi gereken kolon. <see cref="Statement"/> calistirilmaya hazirdir.
/// <see cref="BlockingReason"/> doluysa kolon otomatik eklenemez: karar veriye bakmayi gerektirir
/// ve import durdurulur.
/// </summary>
public sealed record SqlMissingColumn(string ColumnName, string Statement, string? BlockingReason);
/// <summary>
/// Bir tablo script'inin hedefteki canli semayla karsilastirma sonucu.
/// <para>
/// <see cref="MissingColumns"/> eklenebilir (kayipsiz) degisikliklerdir; <see cref="Differences"/>
/// ise var olan bir kolonun olcusunun degismesidir — daraltma veri keser, tip donusumu basarisiz
/// olabilir, bu yuzden otomatik uygulanmaz ve import durdurulur.
/// </para>
/// </summary>
public sealed record SqlSchemaComparison(
string TableName,
string? SchemaName,
IReadOnlyList<SqlMissingColumn> MissingColumns,
IReadOnlyList<SqlColumnDifference> Differences,
IReadOnlyList<string> ExtraColumns);
/// <summary>Var olan bir kolonun hedefteki hali ile script'teki hali arasindaki fark.</summary>
public sealed record SqlColumnDifference(string ColumnName, string Description);
/// <summary>
/// Bir seed script'inin kurdugu tabloyu hedefteki canli semayla karsilastirir.
/// <para>
/// Uretilen scriptler <c>IF OBJECT_ID(...) IS NULL BEGIN CREATE TABLE ... END</c> kalibiyla
/// sarilidir: hedefte tablo varsa <c>CREATE</c> blogu tamamen atlanir. Dolayisiyla eski semali bir
/// tabloya yeni bir kolon script'in kendisiyle <b>hicbir zaman</b> gelmez; ekran o kolonu bir alan
/// olarak tanimladigi icin acilista "gecersiz kolon" hatasi verir. Bu sessiz uyusmazligi kapatan
/// yer burasidir.
/// </para>
/// <para>
/// Ayni karsilastirma iki yerde kullanilir: import analizi kullaniciya ne olacagini gosterir,
/// import kapanisi eklenebilir kolonlari uygular. Ikinci bir karsilastirma kopyasi yazilmaz.
/// </para>
/// </summary>
public static class SqlTableSchemaComparer
{
/// <summary>
/// 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.
/// </summary>
/// <param name="tableColumnsAsync">Tablonun canli kolonlari; tablo yoksa bos, okunamazsa null.</param>
/// <param name="tableHasRowsAsync">
/// Tabloda kayit var mi. Yalnizca varsayilani olmayan <c>NOT NULL</c> bir kolon eklenirken
/// sorulur: bos tabloya boyle bir kolon guvenle eklenir, dolu tabloya eklenemez.
/// </param>
public static async Task<SqlSchemaComparison?> CompareAsync(
string sql,
Func<string, Task<IReadOnlyList<SqlLiveColumn>?>> tableColumnsAsync,
Func<string, Task<bool?>> tableHasRowsAsync)
{
var declaration = SqlCreateTableParser.Parse(sql);
if (declaration == null)
return null;
IReadOnlyList<SqlLiveColumn>? 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<string, SqlLiveColumn>(StringComparer.OrdinalIgnoreCase);
foreach (var column in live)
liveByName[column.Name] = column;
var missing = new List<SqlMissingColumn>();
var differences = new List<SqlColumnDifference>();
var declaredNames = new HashSet<string>(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);
}
/// <summary>
/// Kolonun otomatik eklenmesini engelleyen neden; engel yoksa null.
/// <para>
/// Varsayilani olmayan <c>NOT NULL</c> bir kolon dolu bir tabloya eklenemez — veritabani zaten
/// reddeder. Bu durumda ne deger yazilacagi is kararidir; platform tahmin etmez, durur.
/// </para>
/// </summary>
private static async Task<string?> ResolveBlockingReasonAsync(
string tableName,
SqlColumnDeclaration declared,
Func<string, Task<bool?>> 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";
}
/// <summary>
/// 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
/// <b>adi</b> karsilastirilmaz (bkz. <see cref="SqlLiveColumn"/>).
/// </summary>
private static string? DescribeDifference(SqlColumnDeclaration declared, SqlLiveColumn actual)
{
var differences = new List<string>();
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();
}

View file

@ -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;
/// <summary>
/// Export arsivine girecek tek bir dosya. <see cref="Content"/> doluysa dosya diskten degil
/// uretilen icerikten yazilir (paylasilan bir seed dosyasindan suzulmus satirlar); aksi halde
/// <see cref="FullPath"/> doludur. Ikisinden tam olarak biri her zaman doludur.
/// </summary>
public sealed record WizardExportItem(string Path, string Reason, string? FullPath, string? Content);
/// <summary>
/// Bir wizard seed dosyasinin tasinabilir olmasi icin gereken butun seed dosyalarini toplar.
/// <para>
/// 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.
/// </para>
/// <para>
/// Bulunamayan bagimliliklar sessizce atlanmaz; <see cref="Warnings"/> uzerinden cagirana tasinir ve
/// indirmeden once kullaniciya gosterilir.
/// </para>
/// </summary>
public sealed class WizardExportCollector(string scopeRoot)
{
/// <summary>Custom component'lerin seed kaynagi olan ListForm ekrani.</summary>
public const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents;
/// <summary>Custom component satirlarinin anahtar alani; bagimliliklar bu adla eslesir.</summary>
public const string CustomComponentNameField = "Name";
private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// 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.
/// </summary>
private static readonly Regex QueryObjectPattern = new(
@"\b(?:FROM|JOIN)\s+(?<name>(?:\[[^\]]+\]|""[^""]+""|\w+)(?:\s*\.\s*(?:\[[^\]]+\]|""[^""]+""|\w+))*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly string scopeRoot = System.IO.Path.GetFullPath(scopeRoot);
private readonly Dictionary<string, WizardExportItem> items = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> customComponents = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> warnings = [];
/// <summary>ListFormCode -> wizard seed dosyasi; alt form cozumu icin bir kez kurulur.</summary>
private Dictionary<string, string>? wizardFilesByListFormCode;
public IReadOnlyList<string> Warnings => warnings;
/// <summary>Verilen wizard seed dosyasinin tasinabilir kapanisini toplar.</summary>
public async Task<IReadOnlyList<WizardExportItem>> CollectAsync(string wizardFilePath)
{
var pending = new Queue<(string FilePath, string Reason)>();
var visited = new HashSet<string>(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 ──────────────────────────────────────────────────────────────────
/// <summary>
/// Ekranin tablosu, verisi, lookup nesneleri ve alt formlari. Alt formlar kuyruga eklenir;
/// kendi bagimliliklari da ayni kurallarla toplanir.
/// </summary>
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 ────────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
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<string>();
var visited = new HashSet<string>(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<string>(GetRowText(row, "Dependencies")))
{
if (!string.IsNullOrWhiteSpace(dependency))
pending.Enqueue(dependency.Trim());
}
foreach (var dataSource in ParseSeedRowList<CustomComponentSeedDataSourceDto>(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ı.");
}
/// <summary>
/// Toplanan component'ler paylasilan seed dosyasinin yalnizca ilgili satirlarina indirgenmis bir
/// kopyasi olarak arsive girer; arsiv kapsamdaki ilgisiz component'leri disari tasimaz.
/// </summary>
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<Dictionary<string, Dictionary<string, object>>?> ReadCustomComponentRowsAsync()
{
var seedFile = await ReadCustomComponentFileAsync();
if (seedFile == null)
return null;
var rows = new Dictionary<string, Dictionary<string, object>>(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<ListFormSeedDataFileDto?> 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<ListFormSeedDataFileDto>(await File.ReadAllTextAsync(sourcePath), JsonReadOptions);
}
catch (JsonException)
{
warnings.Add($"{relativePath}: custom component seed dosyası çözülemedi.");
return null;
}
}
// ── Dosya toplama ──────────────────────────────────────────────────────────────
/// <summary>
/// Bir veritabani nesnesinin scriptini ve (istenirse) CRUD tanimini arsive ekler.
/// <para>
/// Nesnenin hangi saglayici/klasor altinda durdugu seed dosyasinda tutulmaz; bu yuzden kombinasyon
/// tahmin edilmez, kapsamdaki script klasorleri <b>diskten taranir</b> (<see cref="ScriptFolders"/>)
/// ve nesne adiyla eslesen her dosya arsive girer. Yeni bir saglayici ya da script klasoru
/// eklendiginde burasi degismez.
/// </para>
/// <para>
/// Script bulunamamasi <b>hata degildir ve uyari uretmez</b>: her tablo seed ile yonetilmez —
/// ABP'nin kendi tablolari (<c>AbpUsers</c>, <c>AbpRoles</c> …) EF migration'lariyla kurulur ve
/// seed script'leri yoktur. "Bu tablo hedefte var mi" sorusunun tek dogru cevabi hedef
/// veritabanindadir; karari import analizi verir (<see cref="WizardImportValidator"/>), export
/// varsayimda bulunmaz.
/// </para>
/// </summary>
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ı");
}
/// <summary>
/// Kapsamdaki script klasorlerinin kapsam-goreli yollari (<c>sql/object</c>, <c>postgres/execute</c> …),
/// diskte gercekten var olanlar. Bir kez taranir; kapsam kokunun disina cikilmaz.
/// </summary>
private IReadOnlyList<string> ScriptFolders => scriptFolders ??= ScanScriptFolders();
private IReadOnlyList<string>? scriptFolders;
private List<string> ScanScriptFolders()
{
var folders = new List<string>();
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<string> SafeEnumerateDirectories(string path)
{
return Directory.Exists(path) ? Directory.EnumerateDirectories(path) : [];
}
/// <summary>Kapsam icindeki var olan bir dosyayi arsive ekler; kapsam disina cikan ya da bulunmayan yol icin false doner.</summary>
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);
}
/// <summary>
/// Kapsamdaki wizard dosyalarini ekran koduna gore indeksler. Okunamayan dosya indekse girmez;
/// alt form cozumu sirasinda "bulunamadi" uyarisi uretir.
/// </summary>
public static Dictionary<string, string> BuildWizardIndex(string scopeRoot)
{
var index = new Dictionary<string, string>(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<WizardSeedFileDto?> ReadWizardAsync(string filePath)
{
try
{
return WizardSeedFileDto.FromSeedJson(await File.ReadAllTextAsync(filePath));
}
catch (Exception)
{
return null;
}
}
// ── Ortak yardimcilar ──────────────────────────────────────────────────────────
/// <summary>Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur.</summary>
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;
}
/// <summary>Ham bir SQL sorgusundaki FROM/JOIN nesnelerinin adlarini (sema oneki ayiklanmis) dondurur.</summary>
public static IEnumerable<string> ExtractQueryObjectNames(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
yield break;
var seen = new HashSet<string>(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;
}
}
/// <summary>Seed satirindaki bir alani metin olarak okur; JsonElement ve duz deger destekler.</summary>
public static string? GetRowText(Dictionary<string, object>? 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();
}
/// <summary>Seed satirinda JSON dizisi olarak tutulan alanlari cozer; bozuk deger bos liste doner.</summary>
public static List<T> ParseSeedRowList<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<T>>(json, JsonReadOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
}

View file

@ -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;
/// <summary>
/// Import edilecek zip'in staging'e acilmis girdileri: kapsam-goreli yol ve dosya icerigi.
/// </summary>
public sealed record WizardImportStagedEntry(string Path, byte[] Content);
/// <summary>
/// Zip'in hedef ortamda yarim ya da bozuk bir kuruluma yol acip acmayacagini denetler.
/// <para>
/// 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 <see cref="WizardImportIssueSeverityEnum.Error"/> ile
/// raporlanir ve yazma adimi baslatilamaz. Kurulumu bozmayan ama dikkat isteyen durumlar uyaridir.
/// </para>
/// <para>
/// Eksik tablo icin olcut "veritabaninda var" degil, "veritabaninda var <b>ya da</b> zip onu
/// olusturan scripti tasiyor"dur: import kapanisi SQL scriptlerini veri satirlarindan once
/// calistirir, dolayisiyla zip'te scripti olan bir tablo eksik sayilmaz.
/// </para>
/// </summary>
public sealed class WizardImportValidator(
string scopeRoot,
string providerFolder,
Func<string?, string, Task<bool?>> databaseObjectExistsAsync,
Func<string, Task<IReadOnlyList<SqlLiveColumn>?>> tableColumnsAsync,
Func<string, Task<bool?>> tableHasRowsAsync,
Func<string, Task<bool>> listFormExistsAsync)
{
private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true };
private readonly string scopeRoot = System.IO.Path.GetFullPath(scopeRoot);
private readonly List<WizardImportIssueDto> issues = [];
/// <summary>Zip'teki wizard dosyalarinin urettigi ekran kodlari; veri ve alt form kontrolleri buna bakar.</summary>
private readonly HashSet<string> incomingListFormCodes = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Zip'teki scriptlerden turetilen nesne adlari; eksik tablo kontrolu buna bakar.</summary>
private readonly HashSet<string> incomingScriptObjects = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Zip'in getirdigi custom component adlari.</summary>
private readonly HashSet<string> incomingCustomComponents = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Hedefteki custom component adlari; dosya her kontrolde yeniden okunmaz.</summary>
private HashSet<string>? targetCustomComponents;
public async Task<List<WizardImportIssueDto>> ValidateAsync(IReadOnlyCollection<WizardImportStagedEntry> entries)
{
var wizards = new List<(string Path, WizardSeedFileDto Seed)>();
var scripts = new List<WizardImportStagedEntry>();
var dataFiles = new List<(string Path, ListFormSeedDataFileDto File)>();
var crudFiles = new List<(string Path, CrudEndpointSeedFileDto File)>();
var paths = new HashSet<string>(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<ListFormSeedDataFileDto>(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<CrudEndpointSeedFileDto>(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 ───────────────────────────────────────────────────────────────────
/// <summary>
/// Zip yalnizca karsi saglayicinin scriptlerini tasiyorsa scriptler hedefte hic calismaz;
/// tablolar eksik kalir. Kurulumu tek basina bozmadigi (tablo zaten var olabilir) icin uyaridir.
/// </summary>
private void ValidateProvider(HashSet<string> 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<string> 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.");
}
}
}
/// <summary>CRUD tanimi, dayandigi tablo olmadan yazilirsa endpoint calisma aninda hata verir.</summary>
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);
}
/// <summary>Veri dosyasi, satirlarin yazilacagi ekran tanimi olmadan uygulanamaz.</summary>
private async Task ValidateDataAsync(string path, ListFormSeedDataFileDto file, HashSet<string> 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);
}
/// <summary>
/// Zip'in getirdigi tablo scriptini hedefteki canli semayla karsilastirir ve sonucu bulguya
/// cevirir. Karsilastirmanin kendisi <see cref="SqlTableSchemaComparer"/> icindedir; ayni
/// karsilastirmayi import kapanisi da kullanir.
/// <para>
/// Ayrim kayip riskine gore yapilir: <b>eksik kolon eklenebilir</b> (kayipsiz) — uyari olarak
/// bildirilir ve kapanista uygulanir. <b>Var olan bir kolonun olcusu degismisse</b> durulur —
/// daraltma veri keser, tip donusumu basarisiz olabilir; dogru islem veriye bakmayi gerektirir.
/// </para>
/// </summary>
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)}.",
});
}
}
/// <summary>
/// Custom component satirlarinin bagimlilik zinciri ve veri kaynaklari. Eksik bir bagimlilik
/// tarayicida derleme hatasina, eksik bir CRUD dosyasi calismayan bir endpoint'e doner.
/// </summary>
private void ValidateCustomComponentRows(string path, ListFormSeedDataFileDto file, HashSet<string> 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<string>(
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<CustomComponentSeedDataSourceDto>(
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.");
}
}
}
/// <summary>Custom wizard'in bagli oldugu bilesenin tanimi zip'te ya da hedefte olmali.</summary>
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.");
}
/// <summary>Veri dosyasinin custom component seed kaynagi olup olmadigi.</summary>
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 ────────────────────────────────────────────────────────────────
/// <summary>
/// Nesne hedefte yoksa ve zip onu olusturan scripti tasimiyorsa hata uretir. Nesne varligi
/// sorgulanamadiginda (baglanti yok, izin yok) karar verilemez; bu durumda uyari birakilir.
/// </summary>
/// <param name="repairableTableName">
/// Nesne bir tabloysa adi; bulguya islenir ve istemci tablo tasarimcisini olusturma modunda acar.
/// View gibi tasarimciyla kurulamayan nesnelerde null birakilir.
/// </param>
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<string> ReadTargetCustomComponentNames()
{
if (targetCustomComponents != null)
return targetCustomComponents;
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var relativePath = SeedPathResolver.GetDataFilePath(WizardExportCollector.CustomComponentsListFormCode);
var fullPath = ResolveScopeFilePath(relativePath);
if (fullPath == null)
return targetCustomComponents = names;
try
{
var file = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(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<T>(WizardImportStagedEntry entry) where T : class
{
try
{
var value = JsonSerializer.Deserialize<T>(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;
}
/// <summary>
/// Bulgu ekler. Bu yoldan gecen her <see cref="WizardImportIssueSeverityEnum.Error"/>, arsivin
/// kendi icerigindeki bir eksikliktir (bozuk dosya, eksik bolum, karsiligi olmayan referans) ve
/// hedef ortamda giderilemez; bu yuzden <see cref="WizardImportIssueDto.RequiresReexport"/>
/// isaretlenir. Ortamda giderilebilen engeller (eksik tablo, sema farki) bu yardimciyi
/// kullanmaz, bulgularini kendileri kurar.
/// </summary>
private void Add(WizardImportIssueSeverityEnum severity, string? path, string message)
{
issues.Add(new WizardImportIssueDto
{
Severity = severity,
Path = path,
Message = message,
RequiresReexport = severity == WizardImportIssueSeverityEnum.Error,
});
}
}

View file

@ -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 <see cref="IDataSeedContributor"/> degildir,
/// SqlDataSeeder tarafindan dogrudan cagrilir.
/// </para>
/// 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.
/// <para>
/// Uygulama kurali <see cref="ICrudEndpointSeedApplier"/> icindedir; ayni kural wizard import'unun
/// kapanisinda da kullanilir, ikinci bir kopya yazilmaz.
/// </para>
/// </summary>
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<CrudEndpoint, Guid> _repository;
private readonly IConfiguration _configuration;
private readonly ILogger<CrudDataSeeder> _logger;
public CrudDataSeeder(
IRepository<CrudEndpoint, Guid> repository,
IConfiguration configuration,
ILogger<CrudDataSeeder> 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<CrudEndpointSeedFileDto>(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<CrudEndpointSeedItemDto> 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);
}
}

View file

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

View file

@ -0,0 +1,17 @@
namespace Sozsoft.Platform.Enums;
/// <summary>Import analizinde bulunan bir tutarsizligin agirligi.</summary>
public enum WizardImportIssueSeverityEnum
{
/// <summary>
/// Import edilirse yarim ya da bozuk bir kurulum olusur (eksik tablo, cozulemeyen bagimlilik,
/// bozuk dosya). Bu agirlikta bir bulgu varken dosya yazma adimi baslatilamaz.
/// </summary>
Error = 0,
/// <summary>
/// Dikkat edilmesi gereken ama kurulumu bozmayan durum (saglayici uyusmazligi, hedefte ayni
/// kodla baska bir wizard olmasi). Import engellenmez.
/// </summary>
Warning = 1,
}

View file

@ -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;
/// <summary>Bir CRUD seed dosyasinin uygulanma sonucu.</summary>
public record CrudEndpointSeedApplyResult(string Path, int Inserted, int Updated, int Deleted, int Skipped, string Error = null);
/// <summary>
/// <c>{host|tenants/{tenantId}}/crud/{EntityName}.json</c> dosyalarini veritabanina uygular.
/// </summary>
public interface ICrudEndpointSeedApplier
{
/// <summary>Kapsamdaki butun CRUD seed dosyalarini uygular (migrate/seed akisi).</summary>
Task<IReadOnlyList<CrudEndpointSeedApplyResult>> ApplyScopeAsync(Guid? tenantId);
/// <summary>
/// Kapsam klasorune goreli belirli dosyalari uygular; import gibi dosya bazli akislar butun
/// klasoru taramak yerine yalnizca yazilan dosyalari uygular.
/// </summary>
Task<IReadOnlyList<CrudEndpointSeedApplyResult>> ApplyFilesAsync(Guid? tenantId, IReadOnlyCollection<string> relativePaths);
}
/// <summary>
/// CRUD endpoint tanimlarini seed dosyasindan veritabanina yazar.
/// <para>
/// Politika damga karsilastirmalidir: eksik endpoint her zaman eklenir, ama var olan bir kayit
/// yalnizca dosyanin <c>GeneratedAt</c> 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.
/// </para>
/// <para>
/// <c>CsharpCode</c> bilincli olarak dosyada tutulmaz: entity adi ve operasyondan yeniden
/// turetilebilir. Guncellemede alana dokunulmaz, eklemede bos kalir ve SqlQueryManager'daki
/// "Yeniden Olustur" ile uretilir.
/// </para>
/// </summary>
public class CrudEndpointSeedApplier(
IRepository<CrudEndpoint, Guid> repository,
IConfiguration configuration,
ICurrentTenant currentTenant,
IUnitOfWorkManager unitOfWorkManager,
ILogger<CrudEndpointSeedApplier> logger) : ICrudEndpointSeedApplier, ITransientDependency
{
private static readonly JsonSerializerOptions JsonReadOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<IReadOnlyList<CrudEndpointSeedApplyResult>> 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<IReadOnlyList<CrudEndpointSeedApplyResult>> ApplyFilesAsync(Guid? tenantId, IReadOnlyCollection<string> relativePaths)
{
if (relativePaths == null || relativePaths.Count == 0)
return [];
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(configuration, tenantId));
var results = new List<CrudEndpointSeedApplyResult>();
var filePaths = new List<string>();
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<List<CrudEndpointSeedApplyResult>> ApplyAsync(Guid? tenantId, IReadOnlyCollection<string> filePaths)
{
var results = new List<CrudEndpointSeedApplyResult>();
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<CrudEndpointSeedApplyResult> ApplyFileAsync(string filePath)
{
var fileName = Path.GetFileName(filePath);
try
{
var seedFile = JsonSerializer.Deserialize<CrudEndpointSeedFileDto>(
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);
}
}
/// <summary>
/// Dosyada artik yer almayan operasyonlari kaldirir. Silme yalnizca dosya ilgili kayittan
/// yeniyse yapilir; aksi halde kayit runtime'da eklenmis olabilir ve migrate onu goturmez.
/// </summary>
private async Task<int> DeleteObsoleteAsync(
string fileName,
string entityName,
CrudEndpointSeedFileDto seedFile,
List<CrudEndpoint> 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;
}
/// <summary>
/// Dosyanin kayittan yeni olup olmadigi. Damgasi olmayan (eski bicimde yazilmis) bir dosya
/// hicbir zaman yeni sayilmaz; kullanicinin runtime revizyonu boyle bir dosya yuzunden ezilmez.
/// </summary>
private static bool IsFileNewerThan(DateTime generatedAt, CrudEndpoint endpoint)
{
if (generatedAt == default)
return false;
var recordTime = endpoint.LastModificationTime ?? endpoint.CreationTime;
return generatedAt.ToUniversalTime() > recordTime.ToUniversalTime();
}
}

View file

@ -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`ı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;

View file

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

View file

@ -46,7 +46,7 @@ function DbMigrateLogPanel({ onClose }: DbMigrateLogPanelProps) {
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60">
<div className="flex flex-col w-[800px] max-w-[95vw] h-[520px] max-h-[90vh] rounded-xl shadow-2xl bg-gray-900 border border-gray-700">
<div className="flex flex-col w-[900px] max-w-[95vw] h-[520px] max-h-[90vh] rounded-xl shadow-2xl bg-gray-900 border border-gray-700">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<span className="text-white font-semibold text-sm">DB Migration Logs</span>
{/*

View file

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

View file

@ -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<WizardExportAnalysisDto>({
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<Blob>({

View file

@ -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<string | null>(null)
const [exportingFile, setExportingFile] = useState<string | null>(null)
// Eksik bagimlilik bulundugunda indirmeden once gosterilen onizleme.
const [exportPreview, setExportPreview] = useState<{
file: WizardFileInfoDto
analysis: WizardExportAnalysisDto
} | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const [search, setSearch] = useState('')
const [importFile, setImportFile] = useState<File | null>(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(
<Notification type="danger">
{translate('::App.ListForm.WizardFileExportError')}: {err?.message ?? 'Unknown error'}
</Notification>,
{ 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(
<Notification type="danger">
@ -552,6 +599,55 @@ const WizardFileManager = () => {
</Button>
</Dialog.Footer>
</Dialog>
{/* Eksik bagimlilikli export onizlemesi */}
<Dialog
isOpen={exportPreview !== null}
width={720}
onClose={() => setExportPreview(null)}
onRequestClose={() => setExportPreview(null)}
>
<Dialog.Body>
<div className="flex items-start gap-3">
<FaExclamationTriangle className="mt-1 shrink-0 text-xl text-amber-500" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-gray-800 dark:text-gray-200">
{translate('::App.ListForm.WizardExportIncomplete')}
</p>
<ul className="mt-2 list-disc space-y-0.5 pl-4 text-sm text-amber-700 dark:text-amber-300">
{exportPreview?.analysis.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
<p className="mt-3 text-xs font-medium text-gray-500">
{translate('::App.ListForm.WizardExportContents')} (
{exportPreview?.analysis.entries.length})
</p>
<ul className="mt-1 max-h-56 overflow-auto rounded border border-gray-200 text-xs dark:border-gray-700">
{exportPreview?.analysis.entries.map((entry) => (
<li
key={entry.path}
className="flex items-center gap-2 border-b border-gray-100 px-2 py-1 last:border-b-0 dark:border-gray-800"
>
<span className="min-w-0 flex-1 truncate font-mono text-gray-700 dark:text-gray-300">
{entry.path}
</span>
<span className="shrink-0 text-gray-400">{entry.reason}</span>
</li>
))}
</ul>
</div>
</div>
</Dialog.Body>
<Dialog.Footer className="flex justify-end gap-2">
<Button size="sm" variant="plain" type="button" onClick={() => setExportPreview(null)}>
{translate('::App.Platform.Cancel')}
</Button>
<Button size="sm" variant="solid" type="button" onClick={handleExportAnyway}>
{translate('::App.ListForm.WizardExportDownloadAnyway')}
</Button>
</Dialog.Footer>
</Dialog>
</Container>
)
}

View file

@ -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<ImportPhase>('analyzing')
const [step, setStep] = useState<number>(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<WizardImportAnalysisDto | null>(null)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<string, EntryDecision>>({})
@ -112,6 +139,13 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
const [applied, setApplied] = useState<AppliedRow[]>([])
// Yazilan data/*.json dosyalarinin veritabanina uygulanma sonucu; oturum kapanisinda doner.
const [seedApplied, setSeedApplied] = useState<WizardImportSeedApplyResultDto[]>([])
// Kapanistaki bagimlilik sirasinin ilk iki adimi: SQL nesneleri ve CRUD tanimlari.
const [sqlApplied, setSqlApplied] = useState<WizardImportStepResultDto[]>([])
const [crudApplied, setCrudApplied] = useState<WizardImportStepResultDto[]>([])
// Hedefte eksik olup kapanista eklenen kolonlar.
const [schemaApplied, setSchemaApplied] = useState<WizardImportStepResultDto[]>([])
// Kurulan ekran tanimlari; import bitince ekran calisir durumdadir, migrate beklemez.
const [wizardsApplied, setWizardsApplied] = useState<WizardImportStepResultDto[]>([])
const [currentPath, setCurrentPath] = useState<string | null>(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<string, EntryDecision> = {}
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ı ı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) => (
<li key={key}>
{issue.path && <span className="font-mono">{issue.path}: </span>}
{issue.message}
{(issue.statement || issue.tableName) && (
<div className="mt-1 flex items-start gap-2">
{issue.statement && (
<pre className="min-w-0 flex-1 overflow-x-auto rounded bg-black/5 px-2 py-1 font-mono text-[11px] dark:bg-white/10">
{issue.statement}
</pre>
)}
{issue.statement && (
<Button
size="xs"
variant="plain"
type="button"
icon={<FaCopy />}
title={translate('::App.Platform.Copy')}
onClick={() => navigator.clipboard?.writeText(issue.statement ?? '')}
/>
)}
{issue.tableName && (
<Button
size="xs"
variant="default"
type="button"
icon={<FaTable />}
onClick={() =>
setDesignTable({
schemaName: issue.schemaName || 'dbo',
tableName: issue.tableName as string,
missing: !!issue.tableMissing,
})
}
>
{translate(
issue.tableMissing
? '::App.ListForm.WizardImportCreateTable'
: '::App.ListForm.WizardImportEditTable',
)}
</Button>
)}
</div>
)}
</li>
)
/** Zip acilirken atlanan girdiler; bulgularla ayni adimda gosterilir. */
const renderZipWarnings = () =>
analysis && analysis.warnings.length > 0 ? (
<div className="rounded-lg bg-amber-50 p-3 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaExclamationTriangle />
{translate('::App.ListForm.WizardImportWarnings')} ({analysis.warnings.length})
</div>
<ul className="list-disc space-y-0.5 pl-4">
{analysis.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
) : null
const renderIssues = () => {
const hasZipWarnings = (analysis?.warnings.length ?? 0) > 0
if (blockingIssues.length === 0 && advisoryIssues.length === 0 && !hasZipWarnings) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-gray-400">
<FaCheckCircle className="text-emerald-500" size={28} />
<span>{translate('::App.ListForm.WizardImportNoIssues')}</span>
</div>
)
}
return (
<div className="min-h-0 flex-1 space-y-2 overflow-auto">
{renderZipWarnings()}
{reexportIssues.length > 0 && (
<div className="rounded-lg bg-red-50 p-3 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaTimesCircle />
{translate('::App.ListForm.WizardImportReexportIssues')} ({reexportIssues.length})
</div>
<div className="mb-1">{translate('::App.ListForm.WizardImportReexportHint')}</div>
<ul className="list-disc space-y-1 pl-4">
{reexportIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))}
</ul>
</div>
)}
{repairableIssues.length > 0 && (
<div className="rounded-lg bg-red-50 p-3 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaTimesCircle />
{translate('::App.ListForm.WizardImportBlockingIssues')} ({repairableIssues.length})
</div>
<ul className="list-disc space-y-1 pl-4">
{repairableIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))}
</ul>
</div>
)}
{advisoryIssues.length > 0 && (
<div className="rounded-lg bg-amber-50 p-3 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaExclamationTriangle />
{translate('::App.ListForm.WizardImportAdvisoryIssues')} ({advisoryIssues.length})
</div>
<ul className="list-disc space-y-1 pl-4">
{advisoryIssues.map((issue, index) => renderIssue(issue, `${issue.path ?? ''}#${index}`))}
</ul>
</div>
)}
</div>
)
}
const renderReview = () => (
<div className="flex min-h-0 flex-1 gap-3">
{/* Dosya listesi */}
@ -524,25 +742,82 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
) : (
<span>{translate('::App.ListForm.WizardImportReady')}</span>
)}
{analysis && analysis.warnings.length > 0 && (
<div className="mt-3 w-full max-w-xl rounded-lg bg-amber-50 p-3 text-left text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
<div className="mb-1 flex items-center gap-1 font-medium">
<FaExclamationTriangle />
{translate('::App.ListForm.WizardImportWarnings')}
</div>
<ul className="list-disc space-y-0.5 pl-4">
{analysis.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
)
/** 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 (
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div className="flex items-center gap-2 text-xs font-medium text-gray-700 dark:text-gray-200">
<FaCheck className="text-emerald-500" />
{title}
<span className="text-gray-400">({items.length})</span>
</div>
{hint && <div className="mt-0.5 text-[11px] text-gray-400">{hint}</div>}
<ul className="mt-1 space-y-0.5 pl-5 text-[11px] text-gray-500 dark:text-gray-400">
{items.map((item) => (
<li key={item} className="truncate font-mono">
{item}
</li>
))}
</ul>
</div>
)
}
/**
* 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 (
<div className="min-h-0 flex-1 space-y-2 overflow-auto">
<div className="text-xs text-gray-500 dark:text-gray-400">
{translate('::App.ListForm.WizardImportPlanTitle')}
</div>
{isEmpty && (
<div className="rounded-lg bg-gray-100 p-3 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{translate('::App.ListForm.WizardImportPlanNothing')}
</div>
)}
{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)}
</div>
)
}
const renderProgress = () => (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<Progress
@ -596,6 +871,49 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
<FaCheckCircle />
{translate('::App.ListForm.WizardImportSuccess')}
</div>
{sqlApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">{translate('::App.ListForm.WizardImportSqlApplied')}</div>
<ul className="mt-0.5 space-y-0.5">
{sqlApplied.map((row) => (
<li key={row.path} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">{row.path}</span>
<span className="shrink-0">{row.error ?? row.detail}</span>
</li>
))}
</ul>
</div>
)}
{schemaApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">
{translate('::App.ListForm.WizardImportSchemaApplied')}
</div>
<ul className="mt-0.5 space-y-0.5">
{schemaApplied.map((row) => (
<li key={`${row.path}#${row.detail ?? row.error}`} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">{row.path}</span>
<span className="shrink-0">{row.error ?? row.detail}</span>
</li>
))}
</ul>
</div>
)}
{crudApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">
{translate('::App.ListForm.WizardImportCrudApplied')}
</div>
<ul className="mt-0.5 space-y-0.5">
{crudApplied.map((row) => (
<li key={row.path} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">{row.path}</span>
<span className="shrink-0">{row.error ?? row.detail}</span>
</li>
))}
</ul>
</div>
)}
{seedApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">
@ -618,7 +936,21 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
</ul>
</div>
)}
<div className="mt-1">{translate('::App.ListForm.WizardImportRunMigrateHint')}</div>
{wizardsApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">
{translate('::App.ListForm.WizardImportWizardsApplied')}
</div>
<ul className="mt-0.5 space-y-0.5">
{wizardsApplied.map((row) => (
<li key={row.path} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">{row.path}</span>
<span className="shrink-0">{row.error ?? row.detail}</span>
</li>
))}
</ul>
</div>
)}
{error && (
<div className="mt-2 flex items-start gap-1 text-amber-700 dark:text-amber-300">
<FaExclamationTriangle className="mt-0.5 shrink-0" />
@ -632,13 +964,30 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
</div>
)
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 = (
<Dialog
preventScroll
isOpen={file !== null}
width={1360}
contentClassName="flex flex-col"
closable={phase !== 'running'}
closable={!isBusy}
onClose={handleClose}
onRequestClose={handleClose}
>
@ -647,7 +996,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
<FaFileImport className="text-sky-500" />
<h5 className="mr-1">{translate('::App.ListForm.WizardImportTitle')}</h5>
<span className="truncate font-mono text-xs text-gray-400">{file?.name}</span>
{phase === 'review' && analysis && (
{phase === 'ready' && analysis && (
<div className="ml-auto flex flex-wrap items-center gap-2 text-[11px]">
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
{entries.filter((e) => e.status === WizardImportEntryStatusEnum.New).length}{' '}
@ -664,6 +1013,21 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
)}
</div>
{!error && (
<Steps
className="shrink-0 flex-row flex-wrap gap-y-2 lg:flex-nowrap"
current={step}
onChange={(index) => {
if (canGoTo(index)) setStep(index)
}}
>
<Steps.Item title={translate('::App.ListForm.WizardImportStepFiles')} />
<Steps.Item title={translate('::App.ListForm.WizardImportStepChecks')} />
<Steps.Item title={translate('::App.ListForm.WizardImportStepPlan')} />
<Steps.Item title={translate('::App.ListForm.WizardImportStepApply')} />
</Steps>
)}
{phase === 'analyzing' && (
<div className="flex flex-1 items-center justify-center text-sm text-gray-400">
<span className="animate-pulse">
@ -672,54 +1036,103 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
</div>
)}
{phase === 'review' && error && (
{phase !== 'analyzing' && error && (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-red-500">
<FaExclamationTriangle size={28} />
<span className="break-all text-center">{error}</span>
</div>
)}
{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()}
</ImportDialogBody>
<Dialog.Footer className="mt-1 flex items-center justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
{phase === 'review' && !error && unresolvedCount > 0 && (
{phase === 'ready' && !error && step === STEP.files && unresolvedCount > 0 && (
<span className="mr-auto text-xs text-amber-600 dark:text-amber-400">
{unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')}
</span>
)}
{phase === 'ready' && !error && step === STEP.checks && blockingIssues.length > 0 && (
<span className="mr-auto text-xs text-red-600 dark:text-red-400">
{translate('::App.ListForm.WizardImportBlocked')}
</span>
)}
<Button
size="sm"
type="button"
variant="plain"
icon={<FaTimes />}
disabled={phase === 'running'}
disabled={isBusy}
onClick={handleClose}
>
{translate(
phase === 'finished' || phase === 'rolledBack'
? '::App.Platform.Close'
: '::App.Platform.Cancel',
)}
{translate(isDone ? '::App.Platform.Close' : '::App.Platform.Cancel')}
</Button>
{phase === 'review' && !error && (
{phase === 'ready' && !error && step > STEP.files && (
<Button size="sm" type="button" variant="default" onClick={() => setStep(step - 1)}>
{translate('::App.Platform.Back')}
</Button>
)}
{phase === 'ready' && !error && step < STEP.plan && (
<Button
size="sm"
type="button"
variant="solid"
disabled={!canGoTo(step + 1)}
onClick={() => setStep(step + 1)}
>
{translate('::App.Platform.Next')}
</Button>
)}
{phase === 'ready' && !error && step === STEP.plan && (
<Button
size="sm"
type="button"
variant="solid"
icon={<FaCheck />}
disabled={unresolvedCount > 0 || applyList.length === 0}
disabled={applyList.length === 0 || blockingIssues.length > 0}
onClick={handleStart}
>
{translate('::App.ListForm.WizardImportStart')} ({applyList.length})
{translate('::App.ListForm.WizardImportConfirmStart')} ({applyList.length})
</Button>
)}
</Dialog.Footer>
</Dialog>
)
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 && (
<SqlTableDesignerDialog
isOpen
dataSource={analysis.dataSourceCode}
initialTableData={designTable.missing ? null : designTable}
initialTableName={designTable.missing ? designTable.tableName : null}
onClose={() => setDesignTable(null)}
onDeployed={async () => {
setDesignTable(null)
await runAnalysis(analysis.importId)
}}
/>
)}
</>
)
}
export default WizardImportDialog

View file

@ -89,6 +89,11 @@ interface TableDesignerDialogProps {
dataSource: string | null
onDeployed?: (table: { schemaName: string; tableName: string }) => void | Promise<void>
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(
() =>