Skip to content

Commit 28c3992

Browse files
authored
Merge pull request #2964 from appwrite/csv-import-upsert
feat(migrations): Fail/Skip/Overwrite import options across migration wizard, CSV & JSON import
2 parents 736972c + 0b65897 commit 28c3992

3 files changed

Lines changed: 214 additions & 16 deletions

File tree

src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/+page.svelte

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55
import type { Column, ColumnType } from '$lib/helpers/types';
66
import { Container } from '$lib/layout';
77
import { preferences } from '$lib/stores/preferences';
8-
import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte';
8+
import {
9+
Icon,
10+
Layout,
11+
Divider,
12+
Tooltip,
13+
Selector,
14+
Typography,
15+
Dialog
16+
} from '@appwrite.io/pink-svelte';
917
import type { PageProps } from './$types';
1018
import FilePicker from '$lib/components/filePicker.svelte';
1119
import { page } from '$app/state';
@@ -21,7 +29,7 @@
2129
IconUpload,
2230
IconDownload
2331
} from '@appwrite.io/pink-icons-svelte';
24-
import { type Models } from '@appwrite.io/console';
32+
import { OnDuplicate, type Models } from '@appwrite.io/console';
2533
import { sdk } from '$lib/stores/sdk';
2634
import { goto } from '$app/navigation';
2735
import { resolve } from '$app/paths';
@@ -49,7 +57,11 @@
4957
5058
let isRefreshing = $state(false);
5159
let showImportJson = $state(false);
60+
let showImportOptions = $state(false);
5261
let showCustomColumnsModal = $state(false);
62+
let importOnDuplicate: OnDuplicate = $state(OnDuplicate.Fail);
63+
let pendingFile: Models.File | null = $state(null);
64+
let pendingLocalFile = $state(false);
5365
5466
let columnsError: string = $state(null);
5567
let spreadsheet: SpreadSheet | null = $state(null);
@@ -74,17 +86,28 @@
7486
return queryParam ? `${url}?query=${encodeURIComponent(queryParam)}` : url;
7587
}
7688
77-
async function onSelect(file: Models.File, localFile = false) {
89+
function onSelect(file: Models.File, localFile = false) {
90+
pendingFile = file;
91+
pendingLocalFile = localFile;
92+
importOnDuplicate = OnDuplicate.Fail;
93+
showImportOptions = true;
94+
}
95+
96+
async function startImport() {
97+
if (!pendingFile) return;
98+
99+
showImportOptions = false;
78100
$isCollectionsJsonImportInProgress = true;
79101
80102
try {
81103
await sdk
82104
.forProject(page.params.region, page.params.project)
83105
.migrations.createJSONImport({
84-
bucketId: file.bucketId,
85-
fileId: file.$id,
106+
bucketId: pendingFile.bucketId,
107+
fileId: pendingFile.$id,
86108
resourceId: `${page.params.database}:${page.params.collection}`,
87-
internalFile: localFile
109+
internalFile: pendingLocalFile,
110+
onDuplicate: importOnDuplicate
88111
});
89112
90113
addNotification({
@@ -101,6 +124,7 @@
101124
});
102125
} finally {
103126
$isCollectionsJsonImportInProgress = false;
127+
pendingFile = null;
104128
}
105129
}
106130
@@ -343,6 +367,52 @@
343367
}} />
344368
{/if}
345369

370+
<Dialog title="Import options" bind:open={showImportOptions}>
371+
<Layout.Stack gap="l">
372+
<Typography.Text variant="m-400">
373+
Choose how to handle documents that already exist in this collection.
374+
</Typography.Text>
375+
<Layout.Stack gap="m">
376+
<Selector.Radio
377+
size="s"
378+
bind:group={importOnDuplicate}
379+
name="importOnDuplicate"
380+
value={OnDuplicate.Fail}
381+
label="Fail on duplicate (default)">
382+
<svelte:fragment slot="description">
383+
Import aborts on the first document with a matching ID.
384+
</svelte:fragment>
385+
</Selector.Radio>
386+
<Selector.Radio
387+
size="s"
388+
bind:group={importOnDuplicate}
389+
name="importOnDuplicate"
390+
value={OnDuplicate.Skip}
391+
label="Skip existing documents">
392+
<svelte:fragment slot="description">
393+
Documents with matching IDs will be silently skipped.
394+
</svelte:fragment>
395+
</Selector.Radio>
396+
<Selector.Radio
397+
size="s"
398+
bind:group={importOnDuplicate}
399+
name="importOnDuplicate"
400+
value={OnDuplicate.Overwrite}
401+
label="Overwrite existing documents">
402+
<svelte:fragment slot="description">
403+
Documents with matching IDs will be updated with the imported data.
404+
</svelte:fragment>
405+
</Selector.Radio>
406+
</Layout.Stack>
407+
</Layout.Stack>
408+
<svelte:fragment slot="footer">
409+
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
410+
<Button text on:click={() => (showImportOptions = false)}>Cancel</Button>
411+
<Button on:click={startImport}>Start import</Button>
412+
</Layout.Stack>
413+
</svelte:fragment>
414+
</Dialog>
415+
346416
<Modal
347417
title="Custom columns"
348418
bind:error={columnsError}

src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@
88
import { Container } from '$lib/layout';
99
import { preferences } from '$lib/stores/preferences';
1010
import { canWriteTables, canWriteRows } from '$lib/stores/roles';
11-
import { Icon, Layout, Divider, Tooltip, Typography, Link } from '@appwrite.io/pink-svelte';
11+
import {
12+
Dialog,
13+
Icon,
14+
Layout,
15+
Divider,
16+
Selector,
17+
Tooltip,
18+
Typography,
19+
Link
20+
} from '@appwrite.io/pink-svelte';
1221
import type { PageData } from './$types';
1322
import {
1423
tableColumns,
@@ -34,7 +43,7 @@
3443
IconUpload,
3544
IconDownload
3645
} from '@appwrite.io/pink-icons-svelte';
37-
import type { Models } from '@appwrite.io/console';
46+
import { OnDuplicate, type Models } from '@appwrite.io/console';
3847
import CreateRow from '$database/table-[table]/rows/create.svelte';
3948
import { onDestroy } from 'svelte';
4049
import { isCloud } from '$lib/system';
@@ -56,6 +65,10 @@
5665
5766
let isRefreshing = false;
5867
let showImportCSV = false;
68+
let showImportOptions = false;
69+
let importOnDuplicate: OnDuplicate = OnDuplicate.Fail;
70+
let pendingFile: Models.File | null = null;
71+
let pendingLocalFile = false;
5972
6073
// todo: might need a type fix here.
6174
const filterColumns = writable<Column[]>([]);
@@ -107,17 +120,28 @@
107120
108121
$: disableButton = canShowSuggestionsSheet;
109122
110-
async function onSelect(file: Models.File, localFile = false) {
123+
function onSelect(file: Models.File, localFile = false) {
124+
pendingFile = file;
125+
pendingLocalFile = localFile;
126+
importOnDuplicate = OnDuplicate.Fail;
127+
showImportOptions = true;
128+
}
129+
130+
async function startImport() {
131+
if (!pendingFile) return;
132+
133+
showImportOptions = false;
111134
$isTablesCsvImportInProgress = true;
112135
113136
try {
114137
await sdk
115138
.forProject(page.params.region, page.params.project)
116139
.migrations.createCSVImport({
117-
bucketId: file.bucketId,
118-
fileId: file.$id,
140+
bucketId: pendingFile.bucketId,
141+
fileId: pendingFile.$id,
119142
resourceId: `${page.params.database}:${page.params.table}`,
120-
internalFile: localFile
143+
internalFile: pendingLocalFile,
144+
onDuplicate: importOnDuplicate
121145
});
122146
123147
addNotification({
@@ -134,6 +158,7 @@
134158
});
135159
} finally {
136160
$isTablesCsvImportInProgress = false;
161+
pendingFile = null;
137162
}
138163
}
139164
@@ -434,6 +459,52 @@
434459
}} />
435460
{/if}
436461

462+
<Dialog title="Import options" bind:open={showImportOptions}>
463+
<Layout.Stack gap="l">
464+
<Typography.Text variant="m-400">
465+
Choose how to handle documents that already exist in this table.
466+
</Typography.Text>
467+
<Layout.Stack gap="m">
468+
<Selector.Radio
469+
size="s"
470+
bind:group={importOnDuplicate}
471+
name="importOnDuplicate"
472+
value={OnDuplicate.Fail}
473+
label="Fail on duplicate (default)">
474+
<svelte:fragment slot="description">
475+
Migration aborts on the first row with a matching ID.
476+
</svelte:fragment>
477+
</Selector.Radio>
478+
<Selector.Radio
479+
size="s"
480+
bind:group={importOnDuplicate}
481+
name="importOnDuplicate"
482+
value={OnDuplicate.Skip}
483+
label="Skip existing documents">
484+
<svelte:fragment slot="description">
485+
Documents with matching IDs will be silently skipped.
486+
</svelte:fragment>
487+
</Selector.Radio>
488+
<Selector.Radio
489+
size="s"
490+
bind:group={importOnDuplicate}
491+
name="importOnDuplicate"
492+
value={OnDuplicate.Overwrite}
493+
label="Overwrite existing documents">
494+
<svelte:fragment slot="description">
495+
Documents with matching IDs will be updated with the imported data.
496+
</svelte:fragment>
497+
</Selector.Radio>
498+
</Layout.Stack>
499+
</Layout.Stack>
500+
<svelte:fragment slot="footer">
501+
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
502+
<Button text on:click={() => (showImportOptions = false)}>Cancel</Button>
503+
<Button on:click={startImport}>Start import</Button>
504+
</Layout.Stack>
505+
</svelte:fragment>
506+
</Dialog>
507+
437508
<CreateRow
438509
{table}
439510
bind:showSheet={$showRowCreateSheet.show}

src/routes/(console)/project-[region]-[project]/settings/migrations/(import)/wizard.svelte

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
AppwriteMigrationResource,
1212
FirebaseMigrationResource,
1313
NHostMigrationResource,
14+
OnDuplicate,
1415
SupabaseMigrationResource
1516
} from '@appwrite.io/console';
1617
import { started } from '../stores';
@@ -23,6 +24,7 @@
2324
Fieldset,
2425
Icon,
2526
Layout,
27+
Selector,
2628
Typography
2729
} from '@appwrite.io/pink-svelte';
2830
import { Link } from '$lib/elements';
@@ -38,6 +40,8 @@
3840
import { capitalize } from '$lib/helpers/string';
3941
import { page } from '$app/state';
4042
43+
let importOnDuplicate: OnDuplicate = OnDuplicate.Fail;
44+
4145
const onExit = () => {
4246
resetImportStores();
4347
};
@@ -46,6 +50,15 @@
4650
try {
4751
const resources = migrationFormToResources($formData, $provider.provider);
4852
53+
// Gate onDuplicate to Fail when databases isn't selected. The radios
54+
// are only shown when databases.root is checked, but the local value
55+
// persists across toggles — without this gate, deselecting databases
56+
// after picking Overwrite/Skip would silently apply that mode to
57+
// other resource types (users, teams, functions, etc.) on submit.
58+
const importOptions = {
59+
onDuplicate: $formData.databases.root ? importOnDuplicate : OnDuplicate.Fail
60+
};
61+
4962
switch ($provider.provider) {
5063
case 'appwrite': {
5164
await sdk
@@ -54,7 +67,8 @@
5467
resources: resources as AppwriteMigrationResource[],
5568
endpoint: $provider.endpoint,
5669
projectId: $provider.projectID,
57-
apiKey: $provider.apiKey
70+
apiKey: $provider.apiKey,
71+
...importOptions
5872
});
5973
6074
await invalidate(Dependencies.MIGRATIONS);
@@ -70,7 +84,8 @@
7084
databaseHost: $provider.host,
7185
username: $provider.username || 'postgres',
7286
password: $provider.password,
73-
port: $provider.port || 5432
87+
port: $provider.port || 5432,
88+
...importOptions
7489
});
7590
await invalidate(Dependencies.MIGRATIONS);
7691
break;
@@ -80,7 +95,8 @@
8095
.forProject(page.params.region, page.params.project)
8196
.migrations.createFirebaseMigration({
8297
resources: resources as FirebaseMigrationResource[],
83-
serviceAccount: $provider.serviceAccount
98+
serviceAccount: $provider.serviceAccount,
99+
...importOptions
84100
});
85101
await invalidate(Dependencies.MIGRATIONS);
86102
break;
@@ -95,7 +111,8 @@
95111
adminSecret: $provider.adminSecret,
96112
database: $provider.database || $provider.subdomain,
97113
username: $provider.username || 'postgres',
98-
password: $provider.password
114+
password: $provider.password,
115+
...importOptions
99116
});
100117
101118
await invalidate(Dependencies.MIGRATIONS);
@@ -196,6 +213,46 @@
196213
projectSdk={sdk.forProject(page.params.region, page.params.project)} />
197214
</Layout.Stack>
198215
</Fieldset>
216+
217+
{#if $formData.databases.root}
218+
<Fieldset legend="Import options">
219+
<Layout.Stack gap="m">
220+
<Selector.Radio
221+
size="s"
222+
bind:group={importOnDuplicate}
223+
name="importOnDuplicate"
224+
value={OnDuplicate.Fail}
225+
label="Fail on duplicate (default)">
226+
<svelte:fragment slot="description">
227+
Migration aborts on the first existing resource (database,
228+
table, column, index, or row).
229+
</svelte:fragment>
230+
</Selector.Radio>
231+
<Selector.Radio
232+
size="s"
233+
bind:group={importOnDuplicate}
234+
name="importOnDuplicate"
235+
value={OnDuplicate.Skip}
236+
label="Skip existing resources">
237+
<svelte:fragment slot="description">
238+
Existing resources are left untouched. Only resources missing on
239+
the destination are created.
240+
</svelte:fragment>
241+
</Selector.Radio>
242+
<Selector.Radio
243+
size="s"
244+
bind:group={importOnDuplicate}
245+
name="importOnDuplicate"
246+
value={OnDuplicate.Overwrite}
247+
label="Overwrite existing resources">
248+
<svelte:fragment slot="description">
249+
Existing resources are updated to match the source. Schema drift
250+
and row data are both reconciled.
251+
</svelte:fragment>
252+
</Selector.Radio>
253+
</Layout.Stack>
254+
</Fieldset>
255+
{/if}
199256
{/if}
200257
</Layout.Stack>
201258
</Layout.Stack>

0 commit comments

Comments
 (0)