diff --git a/packages/csv-to-pg/__tests__/__snapshots__/export.test.ts.snap b/packages/csv-to-pg/__tests__/__snapshots__/export.test.ts.snap index 715ddb30a..11678d166 100644 --- a/packages/csv-to-pg/__tests__/__snapshots__/export.test.ts.snap +++ b/packages/csv-to-pg/__tests__/__snapshots__/export.test.ts.snap @@ -94,6 +94,36 @@ exports[`test case test case parser 1`] = ` ('450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', '450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', '450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', 'name here', 'description');" `; +exports[`test case text fields convert empty strings to NULL when preserveEmptyStrings is false 1`] = ` +"INSERT INTO services_public.webauthn_settings ( + id, + database_id, + rp_id, + rp_name +) VALUES + ('450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', '550e8400-e29b-41d4-a716-446655440000', NULL, NULL);" +`; + +exports[`test case text fields preserve empty strings by default 1`] = ` +"INSERT INTO services_public.webauthn_settings ( + id, + database_id, + rp_id, + rp_name +) VALUES + ('450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', '550e8400-e29b-41d4-a716-446655440000', '', '');" +`; + +exports[`test case text fields with null values produce NULL 1`] = ` +"INSERT INTO services_public.webauthn_settings ( + id, + database_id, + rp_id, + rp_name +) VALUES + ('450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', '550e8400-e29b-41d4-a716-446655440000', NULL, NULL);" +`; + exports[`test case uuid[] arrays 1`] = ` "INSERT INTO metaschema_public.primary_key_constraint ( id, diff --git a/packages/csv-to-pg/__tests__/export.test.ts b/packages/csv-to-pg/__tests__/export.test.ts index 6366b59ac..e9e037f58 100644 --- a/packages/csv-to-pg/__tests__/export.test.ts +++ b/packages/csv-to-pg/__tests__/export.test.ts @@ -276,6 +276,88 @@ it('empty array fields emit empty array literal', async () => { expect(sql).toMatchSnapshot(); }); +it('text fields preserve empty strings by default', async () => { + const parser = new Parser({ + schema: 'services_public', + singleStmts: true, + table: 'webauthn_settings', + fields: { + id: 'uuid', + database_id: 'uuid', + rp_id: 'text', + rp_name: 'text' + } + }); + + const sql = await parser.parse([ + { + id: '450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', + database_id: '550e8400-e29b-41d4-a716-446655440000', + rp_id: '', + rp_name: '' + } + ]); + + // Default: empty strings are preserved as '' + expect(sql).not.toContain('NULL'); + expect(sql).toMatchSnapshot(); +}); + +it('text fields convert empty strings to NULL when preserveEmptyStrings is false', async () => { + const parser = new Parser({ + schema: 'services_public', + singleStmts: true, + table: 'webauthn_settings', + preserveEmptyStrings: false, + fields: { + id: 'uuid', + database_id: 'uuid', + rp_id: 'text', + rp_name: 'text' + } + }); + + const sql = await parser.parse([ + { + id: '450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', + database_id: '550e8400-e29b-41d4-a716-446655440000', + rp_id: '', + rp_name: '' + } + ]); + + // Opt-in: empty strings treated as NULL (CSV convention) + expect(sql).toContain('NULL'); + expect(sql).toMatchSnapshot(); +}); + +it('text fields with null values produce NULL', async () => { + const parser = new Parser({ + schema: 'services_public', + singleStmts: true, + table: 'webauthn_settings', + fields: { + id: 'uuid', + database_id: 'uuid', + rp_id: 'text', + rp_name: 'text' + } + }); + + const sql = await parser.parse([ + { + id: '450e3b3b-b68d-4abc-990c-65cb8a1dcdb4', + database_id: '550e8400-e29b-41d4-a716-446655440000', + rp_id: null, + rp_name: null + } + ]); + + // Actual null values should still produce NULL + expect(sql).toContain('NULL'); + expect(sql).toMatchSnapshot(); +}); + it('interval type with string value', async () => { const parser = new Parser({ schema: 'metaschema_modules_public', diff --git a/packages/csv-to-pg/src/parse.ts b/packages/csv-to-pg/src/parse.ts index 921ed04d0..7e74e2bbf 100644 --- a/packages/csv-to-pg/src/parse.ts +++ b/packages/csv-to-pg/src/parse.ts @@ -239,7 +239,7 @@ const makeNullOrThrow = (fieldName: string, rawValue: unknown, type: string, req // type (int, text, etc) // from Array of keys that map to records found (e.g., ['lon', 'lat']) -const getCoercionFunc = (type: string, from: string[], opts: FieldOptions, fieldName: string): CoercionFunc => { +const getCoercionFunc = (type: string, from: string[], opts: FieldOptions, fieldName: string, globalOpts?: GlobalParseOptions): CoercionFunc => { const parseFn = opts.parse || identity; const required = opts.required || false; @@ -436,7 +436,9 @@ const getCoercionFunc = (type: string, from: string[], opts: FieldOptions, field case 'text': return (record: Record): Node => { const rawValue = record[from[0]]; - const value = parseFn(cleanseEmptyStrings(rawValue)); + const preserve = globalOpts?.preserveEmptyStrings !== false; + const cleansed = preserve ? rawValue : cleanseEmptyStrings(rawValue); + const value = parseFn(cleansed); if (isEmpty(value)) { return makeNullOrThrow(fieldName, rawValue, type, required, 'value is empty or null'); } @@ -525,8 +527,13 @@ interface ConfigFields { [key: string]: string | FieldOptions; } +export interface GlobalParseOptions { + preserveEmptyStrings?: boolean; +} + interface Config { fields: ConfigFields; + preserveEmptyStrings?: boolean; } interface TypesMap { @@ -534,6 +541,9 @@ interface TypesMap { } export const parseTypes = (config: Config): TypesMap => { + const globalOpts: GlobalParseOptions = { + preserveEmptyStrings: config.preserveEmptyStrings + }; return Object.entries(config.fields).reduce((m, v) => { let [key, value] = v; let type: string; @@ -552,7 +562,7 @@ export const parseTypes = (config: Config): TypesMap => { type = value.type!; from = getFromValue(value.from || key); } - m[key] = getCoercionFunc(type, from, value, key); + m[key] = getCoercionFunc(type, from, value, key, globalOpts); return m; }, {}); }; diff --git a/packages/csv-to-pg/src/parser.ts b/packages/csv-to-pg/src/parser.ts index d6e6a6a4f..1ea08be79 100644 --- a/packages/csv-to-pg/src/parser.ts +++ b/packages/csv-to-pg/src/parser.ts @@ -15,6 +15,7 @@ interface ParserConfig { input?: string; debug?: boolean; fields: Record; + preserveEmptyStrings?: boolean; } interface CsvOptions {