diff --git a/docs/SOSO-440/FEATURE_CONCEPT.md b/docs/SOSO-440/FEATURE_CONCEPT.md new file mode 100644 index 0000000..168af1e --- /dev/null +++ b/docs/SOSO-440/FEATURE_CONCEPT.md @@ -0,0 +1,913 @@ +# SOSO-440: Write Conversion Policy — Locale-aware Formatierung beim Schreiben + +## Status: Konzept + +| Feld | Wert | +| ---------- | ------------------------------------------------------------- | +| JIRA | SOSO-440 | +| GitHub | #17 (Enhancement) | +| Version | v3.3.0 (geplant, zusammen mit SOSO-439) | +| Abhaengig | SOSO-439 (Locale-aware Validation, in `develop`) | +| Betrifft | DynamicTable, DynamicTableFactory, FormatValidator, Policies | +| Prioritaet | Mittel — funktioniert aktuell durch ISO-Toleranz von AppSheet | + +--- + +## Problemanalyse + +### Ausgangslage nach SOSO-439 + +SOSO-439 loest das **Lesen/Validieren** von Locale-formatierten Daten: + +``` +1. find() → AppSheet gibt: "03/11/2026 21:51:24" (en-US Locale) +2. update() → Validator akzeptiert jetzt Locale-Format ✅ +3. → Wir senden das Locale-Format zurueck an AppSheet +4. → AppSheet versteht es dank Properties.Locale ✅ +``` + +### Was fehlt: Aktive Konvertierung beim Schreiben + +Wenn ein Consumer ISO 8601 sendet (z.B. `"2026-03-12"`), wird das direkt an +AppSheet weitergereicht. AppSheet **toleriert** ISO wenn `Properties.Locale` +gesetzt ist — aber das ist implizites Verhalten, kein expliziter Vertrag. + +``` +Aktueller Write-Path: + User → DynamicTable.add([{ date: "2026-03-12" }]) + → unknownFieldPolicy.apply() + → validateRows() ← prueft ob ISO oder Locale-Format gueltig + → client.add({ rows, properties: { Locale: "de-DE" } }) + → AppSheet empfaengt: "2026-03-12" mit Locale "de-DE" + → AppSheet: "Ok, ich toleriere ISO" ← implizit, nicht garantiert +``` + +### Gewuenschter Write-Path (optional, opt-in) + +``` +Gewuenschter Write-Path (mit WriteConversionPolicy): + User → DynamicTable.add([{ date: "2026-03-12" }]) + → unknownFieldPolicy.apply() + → validateRows() ← prueft ob ISO oder Locale-Format gueltig + → writeConversionPolicy.apply() ← NEU: konvertiert ISO → Locale + → client.add({ rows: [{ date: "12.03.2026" }], properties: { Locale: "de-DE" } }) + → AppSheet empfaengt: "12.03.2026" mit Locale "de-DE" ← explizit korrekt +``` + +--- + +## Architektur-Entscheidungen + +### E1: Strategy Pattern (analog UnknownFieldPolicyInterface) + +**Entscheidung**: Interface + injizierbare Implementierungen, kein Boolean-Flag. + +**Begruendung**: + +- Konsistent mit bestehender Architektur (`UnknownFieldPolicyInterface`) +- Erweiterbar fuer Phase 2 (Percent, Price, Time, Duration) +- Testbar (Mock-Policy, Custom-Policies) +- Consumer kann eigene Policies implementieren + +### E2: Konvertierung NACH Validation + +**Entscheidung**: `validate → convert → send` + +**Begruendung**: +Die Validation stellt sicher, dass nur gueltige Werte konvertiert werden. +Wuerde die Konvertierung vor der Validation stattfinden, koennte sie auf +ungueltige Eingaben angewendet werden und entweder crashen oder irrefuehrende +Fehlermeldungen erzeugen. + +``` +validate("abc-xyz") → ❌ "Kein gueltiges Datum" — klare Fehlermeldung +convert() → wird nie erreicht + +validate("2026-03-12") → ✅ ISO ist gueltig +convert("2026-03-12") → "12.03.2026" — sicher, weil Input validiert +``` + +### E3: NoOp als Default + +**Entscheidung**: Standard-Policy konvertiert nichts (NoOp). + +**Begruendung**: + +- Kein Breaking Change +- Explizites Opt-in durch Consumer +- Backward-kompatibel: bestehender Code funktioniert weiter + +### E4: Phase 1 + Phase 2 Erweiterbarkeit + +**Entscheidung**: Interface-Design erlaubt beliebige Feldtyp-Konvertierungen. +Phase 1 implementiert nur Date/DateTime/ChangeTimestamp. + +--- + +## Interface-Design + +### WriteConversionPolicyInterface + +**Datei**: `src/types/policies.ts` (erweitert bestehende Datei) + +```typescript +/** + * Interface for converting field values before sending to AppSheet API. + * + * Implementations can convert values to locale-specific formats (e.g., ISO dates + * to locale dates), normalize values, or perform any other pre-write transformation. + * + * The policy is applied AFTER validation but BEFORE sending to the API, + * ensuring only valid values are converted. + * + * @category Types + */ +export interface WriteConversionPolicyInterface { + /** + * Convert field values in rows before sending to AppSheet API. + * + * @param tableName - The AppSheet table name (for context/logging) + * @param rows - The validated row objects to convert + * @param fields - Field definitions from the table schema + * @param locale - Optional BCP 47 locale tag for locale-aware conversion + * @returns Converted rows (may have transformed field values) + */ + apply>( + tableName: string, + rows: Partial[], + fields: Record, + locale?: string + ): Partial[]; +} +``` + +**Design-Unterschiede zu UnknownFieldPolicyInterface**: + +| Aspekt | UnknownFieldPolicy | WriteConversionPolicy | +| ---------------- | ------------------------ | ------------------------------------- | +| Zweck | Felder filtern/pruefen | Feldwerte transformieren | +| Input-Kontext | `knownFields: string[]` | `fields: Record` | +| Locale | nicht relevant | `locale?: string` (fuer Formatierung) | +| Mutation | Filtert Felder (Spalten) | Transformiert Werte (Zellinhalte) | +| Position im Path | Vor Validation | Nach Validation | + +Die Policy bekommt `fields` statt `knownFields`, weil sie den **Feldtyp** +kennen muss (Date, DateTime, Percent, etc.) um zu wissen, welche Felder +konvertiert werden muessen. + +--- + +## Implementierungen + +### NoOpWriteConversionPolicy (Default) + +**Datei**: `src/utils/policies/NoOpWriteConversionPolicy.ts` + +```typescript +/** + * No-op write conversion policy. + * + * Passes all rows through without any conversion. This is the default + * policy, maintaining backward compatibility. + * + * @category Policies + */ +export class NoOpWriteConversionPolicy implements WriteConversionPolicyInterface { + apply>( + _tableName: string, + rows: Partial[], + _fields: Record, + _locale?: string + ): Partial[] { + return rows; + } +} +``` + +### LocaleWriteConversionPolicy (Phase 1: Date/DateTime) + +**Datei**: `src/utils/policies/LocaleWriteConversionPolicy.ts` + +````typescript +import { FieldDefinition, WriteConversionPolicyInterface } from '../../types'; +import { getLocaleDateFormat, DateFormatInfo } from '../validators'; + +/** AppSheet field types that contain date values */ +const DATE_TYPES = new Set(['Date', 'DateTime', 'ChangeTimestamp']); + +/** ISO 8601 date pattern */ +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +/** ISO 8601 datetime pattern */ +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T/; + +/** + * Converts ISO 8601 date/datetime values to locale-specific format + * before sending to AppSheet API. + * + * Only converts values that are in ISO format. Values already in + * locale format or other formats are passed through unchanged. + * + * Requires a locale to be set. Without locale, acts as no-op. + * + * @category Policies + * + * @example + * ```typescript + * import { LocaleWriteConversionPolicy, DynamicTableFactory } from '@techdivision/appsheet'; + * + * const factory = new DynamicTableFactory( + * clientFactory, + * schema, + * undefined, // default unknown field policy + * new LocaleWriteConversionPolicy() // convert dates on write + * ); + * ``` + */ +export class LocaleWriteConversionPolicy implements WriteConversionPolicyInterface { + apply>( + _tableName: string, + rows: Partial[], + fields: Record, + locale?: string + ): Partial[] { + // Without locale, no conversion possible + if (!locale) return rows; + + const fmt = getLocaleDateFormat(locale); + + return rows.map((row) => { + const converted = { ...row } as Record; + + for (const [fieldName, fieldDef] of Object.entries(fields)) { + if (!DATE_TYPES.has(fieldDef.type)) continue; + + const value = converted[fieldName]; + if (typeof value !== 'string') continue; + + converted[fieldName] = this.convertDateValue(value, fieldDef.type, fmt); + } + + return converted as Partial; + }); + } + + /** + * Converts a single date/datetime value from ISO to locale format. + * Returns the original value if it's not in ISO format. + */ + private convertDateValue(value: string, fieldType: string, fmt: DateFormatInfo): string { + if (fieldType === 'Date' && ISO_DATE.test(value)) { + return this.isoDateToLocale(value, fmt); + } + + if ((fieldType === 'DateTime' || fieldType === 'ChangeTimestamp') && ISO_DATETIME.test(value)) { + return this.isoDateTimeToLocale(value, fmt); + } + + // Not ISO format — pass through unchanged + return value; + } + + /** + * Converts ISO date (YYYY-MM-DD) to locale format. + * + * @example + * isoDateToLocale("2026-03-12", deDE) → "12.03.2026" + * isoDateToLocale("2026-03-12", enUS) → "03/12/2026" + * isoDateToLocale("2026-03-12", jaJP) → "2026/03/12" + */ + private isoDateToLocale(isoDate: string, fmt: DateFormatInfo): string { + const [year, month, day] = isoDate.split('-'); + const parts: Record = { year, month, day }; + return fmt.partOrder.map((p) => parts[p]).join(fmt.separator); + } + + /** + * Converts ISO datetime (YYYY-MM-DDT...) to locale format. + * + * @example + * isoDateTimeToLocale("2026-03-12T14:30:00.000Z", deDE) → "12.03.2026 14:30:00" + * isoDateTimeToLocale("2026-03-12T14:30:00Z", enUS) → "03/12/2026 14:30:00" + */ + private isoDateTimeToLocale(isoDateTime: string, fmt: DateFormatInfo): string { + // Parse: "2026-03-12T14:30:00.000Z" or "2026-03-12T14:30:00+02:00" + const tIndex = isoDateTime.indexOf('T'); + const datePart = isoDateTime.substring(0, tIndex); + let timePart = isoDateTime.substring(tIndex + 1); + + // Strip timezone suffix (Z, +HH:MM, -HH:MM) + timePart = timePart.replace(/[Z]$/i, '').replace(/[+-]\d{2}:\d{2}$/, ''); + + // Strip milliseconds (.000) + timePart = timePart.replace(/\.\d+$/, ''); + + const localDate = this.isoDateToLocale(datePart, fmt); + return `${localDate} ${timePart}`; + } +} +```` + +--- + +## Integration in DynamicTable + +### Aktueller Write-Path (v3.3.0) + +```typescript +// DynamicTable.add() — aktuell +async add(rows: Partial[]): Promise { + // STEP 1: Unknown field policy + const processedRows = this.unknownFieldPolicy.apply(...); + + // STEP 2: Validate + this.validateRows(processedRows); + + // STEP 3: Send to API + const result = await this.client.add({ ... }); + return result.rows; +} +``` + +### Neuer Write-Path (v3.4.0) + +```typescript +// DynamicTable.add() — neu +async add(rows: Partial[]): Promise { + // STEP 1: Unknown field policy (unveraendert) + const processedRows = this.unknownFieldPolicy.apply(...); + + // STEP 2: Validate (unveraendert) + this.validateRows(processedRows); + + // STEP 3: Write conversion (NEU) + const convertedRows = this.writeConversionPolicy.apply( + this.definition.tableName, + processedRows, + this.definition.fields, + this.definition.locale + ); + + // STEP 4: Send to API + const result = await this.client.add({ + tableName: this.definition.tableName, + rows: convertedRows as T[], + properties: this.definition.locale ? { Locale: this.definition.locale } : undefined, + }); + return result.rows; +} +``` + +### DynamicTable Constructor-Erweiterung + +```typescript +export class DynamicTable = Record> { + private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + private readonly writeConversionPolicy: WriteConversionPolicyInterface; // NEU + + constructor( + private client: AppSheetClientInterface, + private definition: TableDefinition, + unknownFieldPolicy?: UnknownFieldPolicyInterface, + writeConversionPolicy?: WriteConversionPolicyInterface // NEU + ) { + this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + this.writeConversionPolicy = writeConversionPolicy ?? new NoOpWriteConversionPolicy(); + } +} +``` + +### DynamicTableFactory Constructor-Erweiterung + +```typescript +export class DynamicTableFactory implements DynamicTableFactoryInterface { + private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + private readonly writeConversionPolicy: WriteConversionPolicyInterface; // NEU + + constructor( + private readonly clientFactory: AppSheetClientFactoryInterface, + private readonly schema: SchemaConfig, + unknownFieldPolicy?: UnknownFieldPolicyInterface, + writeConversionPolicy?: WriteConversionPolicyInterface // NEU + ) { + this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + this.writeConversionPolicy = writeConversionPolicy ?? new NoOpWriteConversionPolicy(); + } + + create = Record>( + connectionName: string, + tableName: string, + runAsUserEmail: string + ): DynamicTable { + // ... (existierende Logik) ... + + return new DynamicTable( + client, + resolvedTableDef, + this.unknownFieldPolicy, + this.writeConversionPolicy // NEU: durchreichen + ); + } +} +``` + +### Betroffene Methoden in DynamicTable + +| Methode | Conversion? | Begruendung | +| ----------- | ----------- | ------------------------------------------ | +| `add()` | Ja | Neue Rows werden geschrieben | +| `update()` | Ja | Bestehende Rows werden aktualisiert | +| `delete()` | Nein | Nur Key-Felder, keine Datumswerte relevant | +| `find()` | Nein | Lese-Operation, keine Werte gesendet | +| `findAll()` | Nein | Lese-Operation | +| `findOne()` | Nein | Lese-Operation | + +--- + +## Kritisches Szenario: Round-Trip (Lesen → Unveraendert Zurueckschreiben) + +Das haeufigste Szenario in der Praxis: Ein Record wird per `find()` gelesen und +die Daten (inkl. Datumsfelder im Locale-Format) werden unveraendert per `update()` +zurueckgeschrieben. + +``` +1. find() → AppSheet gibt: { date: "03/11/2026", status: "Done" } (en-US) +2. User aendert nur status: { date: "03/11/2026", status: "Active" } +3. update() → Validation: "03/11/2026" ist gueltiges en-US Format ✅ (SOSO-439) + → WriteConversion: "03/11/2026" ist NICHT ISO (kein YYYY-MM-DD Match) + → Pass through: "03/11/2026" wird unveraendert gesendet ✅ + → AppSheet: Empfaengt "03/11/2026" mit Locale "en-US" ✅ +``` + +### Warum das funktioniert + +Die `LocaleWriteConversionPolicy` konvertiert **ausschliesslich** ISO-formatierte +Werte. Der ISO-Check ist ein strikter Regex: + +```typescript +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; // Nur "YYYY-MM-DD" +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T/; // Nur "YYYY-MM-DDT..." +``` + +Alle Locale-Formate passieren diesen Check **nicht** und werden 1:1 durchgereicht: + +| Input (von AppSheet) | ISO-Check | Ergebnis | +| ---------------------- | --------- | --------------- | +| `03/11/2026` | ❌ Nein | Durchgereicht | +| `11.03.2026` | ❌ Nein | Durchgereicht | +| `2026/03/11` | ❌ Nein | Durchgereicht | +| `03/11/2026 21:51:24` | ❌ Nein | Durchgereicht | +| `11.03.2026 21:51:24` | ❌ Nein | Durchgereicht | +| `2026-03-12` | ✅ Ja | → Locale-Format | +| `2026-03-12T14:30:00Z` | ✅ Ja | → Locale-Format | + +### Test fuer Round-Trip + +```typescript +describe('Round-trip: find() → update() with locale dates', () => { + it('should pass through locale-formatted dates unchanged (en-US)', () => { + const policy = new LocaleWriteConversionPolicy(); + const fields = { date: { type: 'Date' }, name: { type: 'Text' } }; + // Simulate data as returned by AppSheet find() + const rowsFromAppSheet = [{ date: '03/11/2026', name: 'Test' }]; + const result = policy.apply('t', rowsFromAppSheet, fields, 'en-US'); + expect(result[0].date).toBe('03/11/2026'); // Unchanged! + }); + + it('should pass through locale-formatted datetimes unchanged (de-DE)', () => { + const policy = new LocaleWriteConversionPolicy(); + const fields = { created: { type: 'DateTime' } }; + const rowsFromAppSheet = [{ created: '11.03.2026 21:51:24' }]; + const result = policy.apply('t', rowsFromAppSheet, fields, 'de-DE'); + expect(result[0].created).toBe('11.03.2026 21:51:24'); // Unchanged! + }); + + it('should pass through locale-formatted dates unchanged (ja-JP)', () => { + const policy = new LocaleWriteConversionPolicy(); + const fields = { date: { type: 'Date' } }; + const rowsFromAppSheet = [{ date: '2026/03/11' }]; + const result = policy.apply('t', rowsFromAppSheet, fields, 'ja-JP'); + expect(result[0].date).toBe('2026/03/11'); // Unchanged! + }); +}); +``` + +--- + +## Phase 2: Erweiterbarkeit fuer weitere Feldtypen + +### Offene Recherche: Was erwartet die AppSheet API? + +Bevor Phase 2 implementiert wird, muss geprueft werden, welches Format die +AppSheet API tatsaechlich fuer diese Feldtypen erwartet: + +| Feldtyp | Frage | Zu pruefen | +| ---------- | ------------------------------------------------ | -------------------------------- | +| `Percent` | Erwartet API `0.5` oder `50%` oder `50`? | AppSheet Doku + empirischer Test | +| `Price` | Erwartet API `19.99` oder `19,99` oder `€19.99`? | Abhaengig von Locale? | +| `Decimal` | Dezimaltrenner: `.` oder `,` je nach Locale? | AppSheet API Verhalten testen | +| `Time` | Format: `HH:mm:ss` oder locale-abhaengig? | 12h vs 24h Format? | +| `Duration` | Format: `HH:MM:SS` oder Sekunden als Zahl? | AppSheet Doku pruefen | + +### Wie das Interface Phase 2 unterstuetzt + +Die `LocaleWriteConversionPolicy` kann ohne Interface-Aenderung um +weitere Feldtypen erweitert werden: + +```typescript +// Phase 2 Erweiterung (Pseudo-Code) +class LocaleWriteConversionPolicy implements WriteConversionPolicyInterface { + apply(tableName, rows, fields, locale): Partial[] { + if (!locale) return rows; + const fmt = getLocaleDateFormat(locale); + + return rows.map((row) => { + const converted = { ...row }; + for (const [fieldName, fieldDef] of Object.entries(fields)) { + const value = converted[fieldName]; + + switch (fieldDef.type) { + // Phase 1 (bereits implementiert) + case 'Date': + case 'DateTime': + case 'ChangeTimestamp': + converted[fieldName] = this.convertDateValue(value, fieldDef.type, fmt); + break; + + // Phase 2 (Zukunft) + case 'Percent': + converted[fieldName] = this.convertPercent(value, locale); + break; + case 'Price': + converted[fieldName] = this.convertPrice(value, locale); + break; + case 'Decimal': + converted[fieldName] = this.convertDecimal(value, locale); + break; + } + } + return converted; + }); + } +} +``` + +Alternativ koennte Phase 2 als separate Policy implementiert werden +(`NumericLocaleWriteConversionPolicy`), die mit der bestehenden +`LocaleWriteConversionPolicy` kombiniert wird — z.B. ueber eine +`CompositeWriteConversionPolicy`: + +```typescript +/** + * Kombiniert mehrere Write-Conversion-Policies sequentiell. + * Jede Policy transformiert die Ausgabe der vorherigen. + */ +class CompositeWriteConversionPolicy implements WriteConversionPolicyInterface { + constructor(private readonly policies: WriteConversionPolicyInterface[]) {} + + apply(tableName, rows, fields, locale): Partial[] { + let result = rows; + for (const policy of this.policies) { + result = policy.apply(tableName, result, fields, locale); + } + return result; + } +} + +// Nutzung: +const conversion = new CompositeWriteConversionPolicy([ + new LocaleWriteConversionPolicy(), // Phase 1: Dates + new NumericLocaleWriteConversionPolicy(), // Phase 2: Percent, Price +]); +``` + +--- + +## Dateiuebersicht + +### Neue Dateien + +| Datei | Beschreibung | +| ---------------------------------------------------------- | -------------------------------- | +| `src/utils/policies/NoOpWriteConversionPolicy.ts` | Default: keine Konvertierung | +| `src/utils/policies/LocaleWriteConversionPolicy.ts` | ISO → Locale Datumskonvertierung | +| `tests/utils/policies/NoOpWriteConversionPolicy.test.ts` | Tests fuer NoOp Policy | +| `tests/utils/policies/LocaleWriteConversionPolicy.test.ts` | Tests fuer Locale-Konvertierung | +| `tests/client/DynamicTable.writeConversion.test.ts` | Integrationstests im Write-Path | + +### Geaenderte Dateien + +| Datei | Aenderung | Breaking? | +| ----------------------------------- | ------------------------------------------------------------------ | --------- | +| `src/types/policies.ts` | `WriteConversionPolicyInterface` hinzufuegen | Nein | +| `src/client/DynamicTable.ts` | Neuer Constructor-Parameter, Conversion-Step in `add()`/`update()` | Nein | +| `src/client/DynamicTableFactory.ts` | Neuer Constructor-Parameter, durchreichen an DynamicTable | Nein | +| `src/utils/policies/index.ts` | Exports fuer neue Policies | Nein | +| `src/index.ts` | Re-Exports | Nein | + +--- + +## Test-Strategie + +### Unit-Tests: NoOpWriteConversionPolicy + +```typescript +describe('NoOpWriteConversionPolicy', () => { + it('should return rows unchanged', () => { + const policy = new NoOpWriteConversionPolicy(); + const rows = [{ date: '2026-03-12', name: 'Test' }]; + const fields = { date: { type: 'Date' }, name: { type: 'Text' } }; + const result = policy.apply('table', rows, fields, 'de-DE'); + expect(result).toEqual(rows); + }); + + it('should return rows unchanged without locale', () => { + const policy = new NoOpWriteConversionPolicy(); + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('table', rows, { date: { type: 'Date' } }); + expect(result).toEqual(rows); + }); +}); +``` + +### Unit-Tests: LocaleWriteConversionPolicy + +```typescript +describe('LocaleWriteConversionPolicy', () => { + const policy = new LocaleWriteConversionPolicy(); + + describe('Date conversion', () => { + const fields = { date: { type: 'Date' }, name: { type: 'Text' } }; + + it('should convert ISO date to de-DE format', () => { + const rows = [{ date: '2026-03-12', name: 'Test' }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + expect(result[0].name).toBe('Test'); // Non-date fields unchanged + }); + + it('should convert ISO date to en-US format', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, fields, 'en-US'); + expect(result[0].date).toBe('03/12/2026'); + }); + + it('should convert ISO date to ja-JP format', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, fields, 'ja-JP'); + expect(result[0].date).toBe('2026/03/12'); + }); + + it('should pass through non-ISO dates unchanged', () => { + const rows = [{ date: '12.03.2026' }]; // Already in de-DE + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + }); + + it('should not mutate original rows', () => { + const rows = [{ date: '2026-03-12' }]; + const original = { ...rows[0] }; + policy.apply('t', rows, fields, 'de-DE'); + expect(rows[0]).toEqual(original); + }); + }); + + describe('DateTime conversion', () => { + const fields = { created: { type: 'DateTime' } }; + + it('should convert ISO datetime to de-DE format', () => { + const rows = [{ created: '2026-03-12T14:30:00.000Z' }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should convert ISO datetime with timezone offset', () => { + const rows = [{ created: '2026-03-12T14:30:00+02:00' }]; + const result = policy.apply('t', rows, fields, 'en-US'); + expect(result[0].created).toBe('03/12/2026 14:30:00'); + }); + + it('should convert ISO datetime without timezone', () => { + const rows = [{ created: '2026-03-12T14:30:00' }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should pass through non-ISO datetimes unchanged', () => { + const rows = [{ created: '12.03.2026 14:30:00' }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + }); + + describe('ChangeTimestamp conversion', () => { + const fields = { modified: { type: 'ChangeTimestamp' } }; + + it('should convert ChangeTimestamp like DateTime', () => { + const rows = [{ modified: '2026-03-12T14:30:00Z' }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].modified).toBe('12.03.2026 14:30:00'); + }); + }); + + describe('without locale', () => { + it('should act as no-op without locale', () => { + const fields = { date: { type: 'Date' } }; + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, fields); + expect(result[0].date).toBe('2026-03-12'); + }); + }); + + describe('non-date fields', () => { + it('should not touch Text fields', () => { + const fields = { name: { type: 'Text' }, count: { type: 'Number' } }; + const rows = [{ name: 'Test', count: 42 }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result).toEqual(rows); + }); + + it('should handle null/undefined values', () => { + const fields = { date: { type: 'Date' } }; + const rows = [{ date: undefined }, { date: null }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].date).toBeUndefined(); + expect(result[1].date).toBeNull(); + }); + }); + + describe('multiple rows', () => { + it('should convert all rows', () => { + const fields = { date: { type: 'Date' } }; + const rows = [ + { date: '2026-03-12' }, + { date: '2026-12-25' }, + { date: '12.03.2026' }, // Already locale → pass through + ]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + expect(result[1].date).toBe('25.12.2026'); + expect(result[2].date).toBe('12.03.2026'); + }); + }); +}); +``` + +### Integrationstests: DynamicTable mit WriteConversionPolicy + +```typescript +describe('DynamicTable with WriteConversionPolicy', () => { + it('should apply write conversion in add()', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, // default unknown field policy + new LocaleWriteConversionPolicy() + ); + await table.add([{ id: '1', date: '2026-03-12' }]); + + // Verify client received converted date + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '12.03.2026' })], + }) + ); + }); + + it('should apply write conversion in update()', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + await table.update([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '12.03.2026' })], + }) + ); + }); + + it('should NOT apply write conversion in delete()', async () => { + const table = new DynamicTable( + mockClient, + tableDefWithDateKey, + undefined, + new LocaleWriteConversionPolicy() + ); + await table.delete([{ id: '1' }]); + + // delete() should pass keys through unchanged + expect(mockClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [{ id: '1' }], + }) + ); + }); + + it('should use NoOp by default (backward compatible)', async () => { + const table = new DynamicTable(mockClient, tableDef); + await table.add([{ id: '1', date: '2026-03-12' }]); + + // ISO should be sent unchanged + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '2026-03-12' })], + }) + ); + }); +}); +``` + +--- + +## Consumer-Nutzung + +### MCP-Server Beispiel + +```typescript +import { + AppSheetClientFactory, + DynamicTableFactory, + SchemaLoader, + SchemaManager, + LocaleWriteConversionPolicy, +} from '@techdivision/appsheet'; + +// Opt-in: Enable locale write conversion +const clientFactory = new AppSheetClientFactory(); +const schema = SchemaLoader.fromYaml('./schema.yaml'); + +const tableFactory = new DynamicTableFactory( + clientFactory, + schema, + undefined, // default unknown field policy (Strip) + new LocaleWriteConversionPolicy() // convert ISO dates to locale format +); + +const db = new SchemaManager(tableFactory); + +// Now all write operations automatically convert dates +const table = db.table('default', 'worklogs', 'user@example.com'); +await table.add([{ date: '2026-03-12' }]); +// → AppSheet receives: { date: "12.03.2026" } with Locale: "de-DE" +``` + +### Direkter DynamicTable-Einsatz + +```typescript +import { DynamicTable, LocaleWriteConversionPolicy } from '@techdivision/appsheet'; + +const table = new DynamicTable( + client, + tableDefinition, + undefined, // default strip policy + new LocaleWriteConversionPolicy() +); + +await table.add([{ date: '2026-03-12' }]); +// Converted to locale format before sending +``` + +--- + +## Risikobewertung + +| Risiko | Einstufung | Mitigation | +| ------------------------------------------- | ----------- | ---------------------------------------------------------------------- | +| Breaking Change | Kein Risiko | Neuer optionaler Parameter, Default ist NoOp | +| Doppel-Konvertierung (bereits im Locale) | Niedrig | Policy erkennt ISO-Pattern und konvertiert nur ISO-Werte | +| Timezone-Verlust bei DateTime-Konvertierung | Niedrig | AppSheet arbeitet nicht mit Timezones; Locale-Format hat keine TZ-Info | +| Phase 2 erfordert API-Recherche | Mittel | Eigenes Issue vor Implementierung | +| Constructor-Reihenfolge der Parameter | Niedrig | Optionale Parameter am Ende, bestehende Signatur bleibt kompatibel | + +--- + +## Implementierungsplan + +| Phase | Aufwand | Beschreibung | +| ---------------------------------- | ------- | ----------------------------------------------------------- | +| 1. Interface | 0.5h | `WriteConversionPolicyInterface` in `src/types/policies.ts` | +| 2. NoOpWriteConversionPolicy | 0.5h | Default-Implementierung + Tests | +| 3. LocaleWriteConversionPolicy | 2h | Date/DateTime/ChangeTimestamp Konvertierung + Tests | +| 4. DynamicTable Integration | 1h | Constructor, `add()`, `update()` erweitern + Tests | +| 5. DynamicTableFactory Integration | 0.5h | Constructor-Parameter durchreichen | +| 6. Exports | 0.5h | `src/utils/policies/index.ts`, `src/index.ts` | +| 7. Dokumentation | 1h | CLAUDE.md, Code-Beispiele | +| **Gesamt** | **~6h** | **1 Tag** | + +--- + +## Offene Fragen + +1. **SchemaManager Constructor**: Aktuell nimmt `SchemaManager` eine `DynamicTableFactoryInterface`. + Die `WriteConversionPolicy` wird ueber die `DynamicTableFactory` injiziert. + Soll `SchemaManager` alternativ auch direkt eine Policy akzeptieren (Convenience)? + → Empfehlung: Nein, ueber Factory reicht. Haelt SchemaManager schlank. + +2. **Phase 2 Timing**: Wann soll die API-Recherche fuer Percent/Price/Decimal stattfinden? + → Empfehlung: Eigenes Issue (SOSO-441?) erstellen, nicht blockierend fuer Phase 1. + +3. **CompositeWriteConversionPolicy**: Soll die Composite-Policy Teil von Phase 1 sein? + → Empfehlung: Nein, erst wenn Phase 2 tatsaechlich kommt. YAGNI. diff --git a/docs/SOSO-446/FEATURE_CONCEPT.md b/docs/SOSO-446/FEATURE_CONCEPT.md new file mode 100644 index 0000000..ff00760 --- /dev/null +++ b/docs/SOSO-446/FEATURE_CONCEPT.md @@ -0,0 +1,684 @@ +# CLI: Automatische Locale-Erkennung bei inspect und add-table + +## Status: Konzept + +| Feld | Wert | +| ---------- | --------------------------------------------------------------- | +| JIRA | SOSO-446 | +| GitHub | #19 (Enhancement) | +| Version | v3.3.0 (zusammen mit SOSO-439 + SOSO-440) | +| Abhaengig | SOSO-439 (Locale-aware Validation), SOSO-440 (Write Conversion) | +| Betrifft | SchemaInspector, CLI commands (inspect, add-table), Types | +| Prioritaet | Niedrig — Workaround: manuell `locale` im YAML setzen | + +--- + +## Problemanalyse + +### Ausgangslage + +Die CLI-Commands `inspect` und `add-table` generieren Schema-YAML ohne `locale`-Feld: + +```yaml +# Aktuelle Ausgabe von: npx appsheet inspect --tables extract_worklog +connections: + default: + appId: ${APPSHEET_APP_ID} + applicationAccessKey: ${APPSHEET_ACCESS_KEY} + tables: + worklogs: + tableName: extract_worklog + keyField: id + # ← kein locale! + fields: + date: + type: Date + required: false +``` + +### Konsequenz + +Ohne `locale` im Schema: + +- **SOSO-439**: Validation laeuft im permissiven Modus (akzeptiert jedes plausible Datumsformat) +- **SOSO-440**: `LocaleWriteConversionPolicy` agiert als NoOp (kein Locale = keine Konvertierung) + +Der Consumer muss nach jedem `inspect` manuell `locale` an jede Tabelle oder Connection haengen. + +--- + +## Loesung: Automatische Locale-Erkennung + +### Kernidee + +Die CLI holt bei `inspect` und `add-table` bereits bis zu 100 Sample-Rows per API. +Date/DateTime-Felder in diesen Rows sind im Locale-Format des AppSheet-Apps formatiert +(z.B. `"12.03.2026"` fuer de-DE, `"03/12/2026"` fuer en-US). + +Aus diesen Werten kann der `SchemaInspector` das Locale **automatisch erkennen** — +kein CLI-Parameter noetig. + +### Gewuenschte Ausgabe + +```yaml +# Automatisch generiert — Locale erkannt aus API-Daten +connections: + default: + appId: ${APPSHEET_APP_ID} + applicationAccessKey: ${APPSHEET_ACCESS_KEY} + locale: de-DE # ← automatisch erkannt + tables: + worklogs: + tableName: extract_worklog + keyField: id + locale: de-DE # ← automatisch erkannt + fields: + date: + type: Date + required: false +``` + +--- + +## Erkennungsalgorithmus + +### Uebersicht + +``` +Sample-Rows holen + → Date/DateTime-Felder identifizieren (bereits in inferType()) + → Datumswerte sammeln (nicht-ISO, nicht-leer) + → Separator erkennen ("." oder "/") + → Part-Reihenfolge bestimmen (DMY, MDY, YMD) + → Mapping auf repraesentatives Locale +``` + +### Schritt 1: Datumswerte sammeln + +Aus den Sample-Rows werden alle Werte gesammelt, die als Date oder DateTime +erkannt wurden. ISO-Werte (`YYYY-MM-DD`, `YYYY-MM-DDT...`) werden ignoriert, +da sie kein Locale-Signal enthalten. + +```typescript +// Pseudo-Code +const dateValues = sampleRows + .flatMap((row) => dateFieldNames.map((f) => row[f])) + .filter((v) => typeof v === 'string') + .filter((v) => !ISO_DATE.test(v) && !ISO_DATETIME.test(v)); +``` + +Fuer DateTime-Werte (z.B. `"12.03.2026 14:30:00"`) wird nur der Date-Teil +vor dem Leerzeichen analysiert. + +### Schritt 2: Separator erkennen + +Der Separator wird aus dem ersten nicht-ISO Datumswert extrahiert: + +| Datumswert | Separator | +| ------------ | --------- | +| `12.03.2026` | `.` | +| `03/12/2026` | `/` | +| `2026/03/12` | `/` | + +```typescript +// Separator ist das erste nicht-numerische Zeichen im Date-Teil +const separator = datePart.match(/[^0-9]/)?.[0]; +``` + +### Schritt 3: Part-Reihenfolge bestimmen + +Die drei Teile eines Datums (Tag, Monat, Jahr) werden anhand ihrer Werte identifiziert: + +**Eindeutige Faelle:** + +- 4-stelliger Part → Jahr +- Part > 12 (und nicht Jahr) → muss Tag sein (Monat max. 12) + +**Algorithmus:** + +``` +1. Teile den Date-String am Separator → [part1, part2, part3] +2. Finde das Jahr (4-stelliger Part): + - Position 0 → YMD (z.B. ja-JP: "2026/03/12") + - Position 2 → DMY oder MDY +3. Bei Jahr an Position 2 — DMY vs MDY bestimmen: + - Ueber ALLE Sample-Datumswerte iterieren + - Wenn je ein erster Part > 12 → DMY (erster Part ist Tag) + - Wenn je ein zweiter Part > 12 → MDY (zweiter Part ist Tag) + - Wenn KEIN Part > 12 → mehrdeutig +``` + +**Beispiele:** + +| Werte im Dataset | Erkennung | +| -------------------------------------- | -------------------------- | +| `25.03.2026`, `12.06.2026` | DMY (25 > 12) | +| `03/25/2026`, `06/12/2026` | MDY (25 > 12 an Pos 2) | +| `2026/03/12`, `2026/12/25` | YMD (Jahr an Pos 0) | +| `03/06/2026`, `01/12/2026` (alle ≤ 12) | Mehrdeutig → Default en-US | + +### Schritt 4: Mapping auf repraesentatives Locale + +| Part-Order | Separator | Locale | Beispiel | +| ---------- | --------- | ------- | ------------ | +| DMY | `.` | `de-DE` | `12.03.2026` | +| DMY | `/` | `en-GB` | `12/03/2026` | +| MDY | `/` | `en-US` | `03/12/2026` | +| YMD | `/` | `ja-JP` | `2026/03/12` | +| Mehrdeutig | `/` | `en-US` | Default | +| Mehrdeutig | `.` | `de-DE` | Default | + +**Begruendung fuer Defaults:** + +- `/` + mehrdeutig → `en-US`: Haeufigste AppSheet-Deployments sind US-basiert +- `.` + mehrdeutig → `de-DE`: Punkt als Separator ist fast ausschliesslich DACH-Raum + +### Schritt 5: Ergebnis + +- Locale wird in `TableInspectionResult.locale` gespeichert +- `generateSchema()` setzt es auf Connection- und Table-Ebene +- Bei mehrdeutiger Erkennung: Warning im CLI-Output + +--- + +## Sonderfaelle + +### Keine Datumswerte vorhanden + +Wenn eine Tabelle keine Date/DateTime/ChangeTimestamp-Felder hat oder alle +Datumswerte leer/null/ISO sind, kann kein Locale erkannt werden. + +→ `locale` bleibt `undefined` fuer diese Tabelle. + +### Leere Tabelle + +Wenn die Tabelle keine Rows hat, gibt es keine Daten zur Analyse. + +→ `locale` bleibt `undefined` (wie bisher auch fuer Feldtypen). + +### Alle Tabellen im selben App teilen ein Locale + +AppSheet setzt das Locale auf App-Ebene. Alle Tabellen einer Connection +geben Daten im selben Format zurueck. + +→ Erkennung bei der ersten Tabelle mit Date-Feldern reicht. +Wird aber trotzdem pro Tabelle durchgefuehrt (Robustheit). +`generateSchema()` setzt das Connection-Level Locale auf das +am haeufigsten erkannte Locale aller Tabellen. + +### DateTime-Werte + +DateTime-Werte wie `"12.03.2026 14:30:00"` werden vor der Analyse +am Leerzeichen gesplittet. Nur der Date-Teil wird fuer die Erkennung +verwendet. + +--- + +## Betroffene Dateien + +### `src/types/schema.ts` + +**TableInspectionResult erweitern:** + +```typescript +export interface TableInspectionResult { + tableName: string; + keyField: string; + fields: Record; + locale?: string; // NEU: automatisch erkanntes Locale + warning?: string; +} +``` + +### `src/cli/SchemaInspector.ts` + +**Neue Methode: `detectLocale()`** + +```typescript +/** Locale-Mapping: partOrder + separator → representative locale */ +private static readonly LOCALE_MAP: Record = { + 'day,month,year:.': 'de-DE', + 'day,month,year:/': 'en-GB', + 'month,day,year:/': 'en-US', + 'year,month,day:/': 'ja-JP', +}; + +/** Default locales for ambiguous cases, keyed by separator */ +private static readonly DEFAULT_LOCALE: Record = { + '/': 'en-US', + '.': 'de-DE', +}; + +/** + * Detect locale from date values in sample rows. + * + * Analyzes Date/DateTime field values to determine the locale + * of the AppSheet app by examining separator and part order. + * + * @param rows - Sample rows from AppSheet API + * @param fields - Already-inferred field definitions + * @returns Detected locale or undefined if detection not possible + */ +private detectLocale( + rows: Record[], + fields: Record +): { locale?: string; ambiguous: boolean } { + // 1. Collect date field names + const dateFieldNames = Object.entries(fields) + .filter(([, def]) => ['Date', 'DateTime', 'ChangeTimestamp'].includes(def.type)) + .map(([name]) => name); + + if (dateFieldNames.length === 0) { + return { locale: undefined, ambiguous: false }; + } + + // 2. Collect non-ISO date strings + const dateStrings: string[] = []; + for (const row of rows) { + for (const fieldName of dateFieldNames) { + const value = row[fieldName]; + if (typeof value !== 'string') continue; + if (/^\d{4}-\d{2}-\d{2}/.test(value)) continue; // Skip ISO + + // For DateTime, extract date part only + const spaceIdx = value.indexOf(' '); + const datePart = spaceIdx > 0 ? value.substring(0, spaceIdx) : value; + dateStrings.push(datePart); + } + } + + if (dateStrings.length === 0) { + return { locale: undefined, ambiguous: false }; + } + + // 3. Detect separator from first value + const separator = dateStrings[0].match(/[^0-9]/)?.[0]; + if (!separator) { + return { locale: undefined, ambiguous: false }; + } + + // 4. Determine part order + const parts = dateStrings[0].split(separator); + if (parts.length !== 3) { + return { locale: undefined, ambiguous: false }; + } + + // Year position (4-digit part) + const yearPos = parts.findIndex(p => p.length === 4); + if (yearPos === 0) { + // YMD + const key = `year,month,day:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + if (yearPos !== 2) { + return { locale: undefined, ambiguous: false }; + } + + // Year at position 2 → DMY or MDY + // Check all date values for disambiguation + let foundFirstPartGt12 = false; + let foundSecondPartGt12 = false; + + for (const dateStr of dateStrings) { + const p = dateStr.split(separator); + if (p.length !== 3) continue; + const first = parseInt(p[0], 10); + const second = parseInt(p[1], 10); + if (first > 12) foundFirstPartGt12 = true; + if (second > 12) foundSecondPartGt12 = true; + } + + if (foundFirstPartGt12 && !foundSecondPartGt12) { + // First part is day → DMY + const key = `day,month,year:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + if (foundSecondPartGt12 && !foundFirstPartGt12) { + // Second part is day → MDY + const key = `month,day,year:${separator}`; + const locale = SchemaInspector.LOCALE_MAP[key]; + return { locale, ambiguous: false }; + } + + // Ambiguous: no part > 12, or both > 12 (shouldn't happen) + const defaultLocale = SchemaInspector.DEFAULT_LOCALE[separator] || 'en-US'; + return { locale: defaultLocale, ambiguous: true }; +} +``` + +**Integration in `inspectTable()`:** + +```typescript +async inspectTable(tableName: string): Promise { + // ... (bestehende Logik: rows holen, fields inferieren) ... + + // NEU: Locale aus Date-Feldern erkennen + const { locale, ambiguous } = this.detectLocale(sampleRows, fields); + + let warning = inspection.warning; + if (ambiguous) { + const msg = `Locale detection ambiguous, defaulting to "${locale}". Please verify.`; + warning = warning ? `${warning}; ${msg}` : msg; + } + + return { + tableName, + keyField: this.guessKeyField(sampleRows[0]), + fields, + locale, // NEU + warning, + }; +} +``` + +**Integration in `generateSchema()`:** + +```typescript +async generateSchema( + _connectionName: string, + tableNames: string[] +): Promise { + const tables: Record = {}; + const detectedLocales: string[] = []; + + for (const tableName of tableNames) { + console.log(`Inspecting table: ${tableName}...`); + const inspection = await this.inspectTable(tableName); + + const tableDef: TableDefinition = { + tableName: inspection.tableName, + keyField: inspection.keyField, + fields: inspection.fields, + }; + + // NEU: Locale setzen wenn erkannt + if (inspection.locale) { + tableDef.locale = inspection.locale; + detectedLocales.push(inspection.locale); + } + + tables[this.toSchemaName(tableName)] = tableDef; + + if (inspection.warning) { + console.warn(` Warning: ${inspection.warning}`); + } + } + + // NEU: Connection-Level Locale = haeufigstes erkanntes Locale + const connectionLocale = this.mostFrequent(detectedLocales); + + const connectionDef: ConnectionDefinition = { + appId: '${APPSHEET_APP_ID}', + applicationAccessKey: '${APPSHEET_ACCESS_KEY}', + tables, + }; + + if (connectionLocale) { + connectionDef.locale = connectionLocale; + } + + return connectionDef; +} + +/** Returns the most frequent string in an array, or undefined if empty */ +private mostFrequent(values: string[]): string | undefined { + if (values.length === 0) return undefined; + const counts = new Map(); + for (const v of values) { + counts.set(v, (counts.get(v) || 0) + 1); + } + let best = values[0]; + let bestCount = 0; + for (const [v, c] of counts) { + if (c > bestCount) { best = v; bestCount = c; } + } + return best; +} +``` + +### `src/cli/commands.ts` + +**Output-Hinweis aktualisieren** (kein `--locale` Parameter): + +```typescript +// In inspect action handler: +console.log('\nPlease review and update:'); +console.log(' - Key fields may need manual adjustment'); +console.log(' - Field types are inferred and may need refinement'); +console.log(' - Add required, enum, and description properties as needed'); +// NEU: Hinweis nur wenn kein Locale erkannt wurde +if (!connection.locale) { + console.log(' - No locale detected. Consider adding locale manually for date validation.'); +} +``` + +**add-table-Command:** + +```typescript +// In add-table action handler: +schema.connections[connection].tables[schemaName] = { + tableName: inspection.tableName, + keyField: inspection.keyField, + locale: inspection.locale, // NEU: automatisch erkannt, kann undefined sein + fields: inspection.fields, +}; +``` + +--- + +## Design-Entscheidung: Beide Ebenen setzen + +Wenn ein Locale erkannt wird, wird es auf **beiden** Ebenen gesetzt: + +- **Connection-Level**: Haeufigstes Locale aller Tabellen, als Default +- **Table-Level**: Pro Tabelle individuell erkannt + +**Begruendung**: Explizit > implizit. Auch wenn alle Tabellen in einem AppSheet-App +dasselbe Locale haben, ist es fuer den Consumer hilfreich, das Locale an jeder Tabelle +explizit zu sehen. + +--- + +## Test-Strategie + +### Unit-Tests: `detectLocale()` + +```typescript +describe('detectLocale', () => { + describe('de-DE detection (DMY with dot)', () => { + it('should detect de-DE from DD.MM.YYYY dates', () => { + const rows = [ + { date: '25.03.2026' }, // 25 > 12 → first part is day → DMY + { date: '12.06.2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('en-US detection (MDY with slash)', () => { + it('should detect en-US from MM/DD/YYYY dates', () => { + const rows = [ + { date: '03/25/2026' }, // 25 > 12 → second part is day → MDY + { date: '06/12/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('en-GB detection (DMY with slash)', () => { + it('should detect en-GB from DD/MM/YYYY dates', () => { + const rows = [ + { date: '25/03/2026' }, // 25 > 12 → first part is day → DMY + { date: '12/06/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('en-GB'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ja-JP detection (YMD with slash)', () => { + it('should detect ja-JP from YYYY/MM/DD dates', () => { + const rows = [{ date: '2026/03/12' }, { date: '2026/12/25' }]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('ja-JP'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ambiguous cases', () => { + it('should default to en-US when slash separator is ambiguous', () => { + const rows = [ + { date: '03/06/2026' }, // Both parts ≤ 12 + { date: '01/12/2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(true); + }); + + it('should default to de-DE when dot separator is ambiguous', () => { + const rows = [ + { date: '03.06.2026' }, // Both parts ≤ 12 + { date: '01.12.2026' }, + ]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(true); + }); + }); + + describe('no detection possible', () => { + it('should return undefined when no date fields exist', () => { + const rows = [{ name: 'Test' }]; + const fields = { name: { type: 'Text' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBeUndefined(); + }); + + it('should return undefined when all dates are ISO', () => { + const rows = [{ date: '2026-03-12' }]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBeUndefined(); + }); + + it('should return undefined when date fields are empty', () => { + const rows = [{ date: null }, { date: undefined }]; + const fields = { date: { type: 'Date' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBeUndefined(); + }); + }); + + describe('DateTime values', () => { + it('should detect locale from DateTime values (date part only)', () => { + const rows = [{ created: '25.03.2026 14:30:00' }]; + const fields = { created: { type: 'DateTime' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('de-DE'); + expect(result.ambiguous).toBe(false); + }); + }); + + describe('ChangeTimestamp values', () => { + it('should detect locale from ChangeTimestamp values', () => { + const rows = [{ modified: '03/25/2026 09:00:00' }]; + const fields = { modified: { type: 'ChangeTimestamp' } }; + const result = inspector['detectLocale'](rows, fields); + expect(result.locale).toBe('en-US'); + expect(result.ambiguous).toBe(false); + }); + }); +}); +``` + +### Integration-Tests: `inspectTable()` mit Locale + +```typescript +describe('inspectTable with locale detection', () => { + it('should include detected locale in result', async () => { + // Mock client returns rows with de-DE formatted dates + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '25.03.2026', name: 'Test' }, + { id: '2', date: '12.06.2026', name: 'Other' }, + ], + }); + const result = await inspector.inspectTable('extract_worklog'); + expect(result.locale).toBe('de-DE'); + }); + + it('should add warning for ambiguous locale', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ id: '1', date: '03/06/2026' }], + }); + const result = await inspector.inspectTable('extract_worklog'); + expect(result.locale).toBe('en-US'); + expect(result.warning).toContain('ambiguous'); + }); +}); +``` + +### Integration-Tests: `generateSchema()` mit Locale + +```typescript +describe('generateSchema with auto-detected locale', () => { + it('should set locale on connection and table level', async () => { + // Mock: table has de-DE dates + const result = await inspector.generateSchema('default', ['extract_worklog']); + expect(result.locale).toBe('de-DE'); + expect(result.tables['worklogs'].locale).toBe('de-DE'); + }); + + it('should not set locale when no date fields exist', async () => { + // Mock: table has only Text fields + const result = await inspector.generateSchema('default', ['extract_config']); + expect(result.locale).toBeUndefined(); + expect(result.tables['configs'].locale).toBeUndefined(); + }); +}); +``` + +--- + +## Implementierungsplan + +| Phase | Aufwand | Beschreibung | +| --------------------------------- | ------- | ----------------------------------------------------- | +| 1. TableInspectionResult | 0.25h | `locale?: string` hinzufuegen | +| 2. `detectLocale()` | 1.5h | Erkennungsalgorithmus implementieren | +| 3. Integration `inspectTable()` | 0.5h | `detectLocale()` aufrufen, Warning bei Mehrdeutigkeit | +| 4. Integration `generateSchema()` | 0.5h | Locale auf Connection + Table Level setzen | +| 5. `add-table` Command | 0.25h | `inspection.locale` durchreichen | +| 6. Output-Hinweis | 0.25h | Hinweis wenn kein Locale erkannt | +| 7. Tests | 1.5h | Unit + Integration Tests | +| **Gesamt** | **~5h** | | + +--- + +## Risikobewertung + +| Risiko | Einstufung | Mitigation | +| ------------------------------------------ | ------------ | ---------------------------------------------- | +| Breaking Change | Kein Risiko | Neues optionales Feld in TableInspectionResult | +| Falsche Locale-Erkennung (mehrdeutig) | Niedrig | Default en-US/de-DE, Warning im Output | +| Tabelle ohne Date-Felder | Kein Risiko | locale bleibt undefined | +| Leere Tabelle | Kein Risiko | Wie bisher, kein Locale erkennbar | +| Nur ISO-Daten in der Tabelle | Niedrig | Kein Locale erkennbar, bleibt undefined | +| YAML mit undefined-Feldern | Kein Risiko | YAML-Serializer ignoriert undefined-Werte | +| Unbekannter Separator (weder "." noch "/") | Sehr niedrig | Locale bleibt undefined, kein Crash | diff --git a/src/client/DynamicTable.ts b/src/client/DynamicTable.ts index 63dabff..930f19c 100644 --- a/src/client/DynamicTable.ts +++ b/src/client/DynamicTable.ts @@ -4,9 +4,14 @@ * @category Client */ -import { AppSheetClientInterface, TableDefinition, UnknownFieldPolicyInterface } from '../types'; +import { + AppSheetClientInterface, + TableDefinition, + UnknownFieldPolicyInterface, + WriteConversionPolicyInterface, +} from '../types'; import { AppSheetTypeValidator } from '../utils/validators'; -import { StripUnknownFieldPolicy } from '../utils/policies'; +import { StripUnknownFieldPolicy, NoOpWriteConversionPolicy } from '../utils/policies'; /** * Table client with schema-based operations and runtime validation. @@ -36,6 +41,7 @@ import { StripUnknownFieldPolicy } from '../utils/policies'; */ export class DynamicTable = Record> { private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + private readonly writeConversionPolicy: WriteConversionPolicyInterface; /** * Creates a new DynamicTable instance. @@ -43,13 +49,16 @@ export class DynamicTable = Record> { * @param client - AppSheet client for API operations * @param definition - Table schema definition * @param unknownFieldPolicy - Optional policy for handling unknown fields (default: StripUnknownFieldPolicy) + * @param writeConversionPolicy - Optional policy for converting field values before write (default: NoOpWriteConversionPolicy) */ constructor( private client: AppSheetClientInterface, private definition: TableDefinition, - unknownFieldPolicy?: UnknownFieldPolicyInterface + unknownFieldPolicy?: UnknownFieldPolicyInterface, + writeConversionPolicy?: WriteConversionPolicyInterface ) { this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + this.writeConversionPolicy = writeConversionPolicy ?? new NoOpWriteConversionPolicy(); } /** @@ -169,9 +178,17 @@ export class DynamicTable = Record> { // Validate rows this.validateRows(processedRows); + // Apply write conversion (e.g., ISO dates → locale format) + const convertedRows = this.writeConversionPolicy.apply( + this.definition.tableName, + processedRows, + this.definition.fields, + this.definition.locale + ); + const result = await this.client.add({ tableName: this.definition.tableName, - rows: processedRows as T[], + rows: convertedRows as T[], properties: this.definition.locale ? { Locale: this.definition.locale } : undefined, }); return result.rows; @@ -213,9 +230,17 @@ export class DynamicTable = Record> { // Validate rows this.validateRows(processedRows, false); + // Apply write conversion (e.g., ISO dates → locale format) + const convertedRows = this.writeConversionPolicy.apply( + this.definition.tableName, + processedRows, + this.definition.fields, + this.definition.locale + ); + const result = await this.client.update({ tableName: this.definition.tableName, - rows: processedRows as T[], + rows: convertedRows as T[], properties: this.definition.locale ? { Locale: this.definition.locale } : undefined, }); return result.rows; diff --git a/src/client/DynamicTableFactory.ts b/src/client/DynamicTableFactory.ts index 2765653..9991db8 100644 --- a/src/client/DynamicTableFactory.ts +++ b/src/client/DynamicTableFactory.ts @@ -13,8 +13,9 @@ import { AppSheetClientFactoryInterface, SchemaConfig, UnknownFieldPolicyInterface, + WriteConversionPolicyInterface, } from '../types'; -import { StripUnknownFieldPolicy } from '../utils/policies'; +import { StripUnknownFieldPolicy, NoOpWriteConversionPolicy } from '../utils/policies'; import { DynamicTable } from './DynamicTable'; /** @@ -30,13 +31,16 @@ import { DynamicTable } from './DynamicTable'; * * @example * ```typescript - * // Create factory with client factory and schema (default: StripUnknownFieldPolicy) + * // Create factory with client factory and schema (default policies) * const clientFactory = new AppSheetClientFactory(); * const tableFactory = new DynamicTableFactory(clientFactory, schema); * * // Create factory with custom unknown field policy * const strictFactory = new DynamicTableFactory(clientFactory, schema, new ErrorUnknownFieldPolicy()); * + * // Create factory with locale write conversion + * const localeFactory = new DynamicTableFactory(clientFactory, schema, undefined, new LocaleWriteConversionPolicy()); + * * // Create table instances * const usersTable = tableFactory.create('worklog', 'users', 'user@example.com'); * const users = await usersTable.findAll(); @@ -49,6 +53,7 @@ import { DynamicTable } from './DynamicTable'; */ export class DynamicTableFactory implements DynamicTableFactoryInterface { private readonly unknownFieldPolicy: UnknownFieldPolicyInterface; + private readonly writeConversionPolicy: WriteConversionPolicyInterface; /** * Creates a new DynamicTableFactory. @@ -56,13 +61,16 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface { * @param clientFactory - Factory to create AppSheetClient instances * @param schema - Schema configuration with connection definitions * @param unknownFieldPolicy - Optional policy for handling unknown fields in DynamicTable (default: StripUnknownFieldPolicy) + * @param writeConversionPolicy - Optional policy for converting field values before write (default: NoOpWriteConversionPolicy) */ constructor( private readonly clientFactory: AppSheetClientFactoryInterface, private readonly schema: SchemaConfig, - unknownFieldPolicy?: UnknownFieldPolicyInterface + unknownFieldPolicy?: UnknownFieldPolicyInterface, + writeConversionPolicy?: WriteConversionPolicyInterface ) { this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy(); + this.writeConversionPolicy = writeConversionPolicy ?? new NoOpWriteConversionPolicy(); } /** @@ -106,7 +114,12 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface { const resolvedTableDef = effectiveLocale !== tableDef.locale ? { ...tableDef, locale: effectiveLocale } : tableDef; - // Create and return DynamicTable with injected policy - return new DynamicTable(client, resolvedTableDef, this.unknownFieldPolicy); + // Create and return DynamicTable with injected policies + return new DynamicTable( + client, + resolvedTableDef, + this.unknownFieldPolicy, + this.writeConversionPolicy + ); } } diff --git a/src/types/policies.ts b/src/types/policies.ts index a46dcbd..1b50b91 100644 --- a/src/types/policies.ts +++ b/src/types/policies.ts @@ -1,16 +1,18 @@ /** - * Unknown Field Policy Interface + * Policy interfaces for DynamicTable behavior customization * - * Defines how DynamicTable handles fields in row objects that are not - * defined in the table schema. Implementations decide what happens: - * ignore them, strip them, throw an error, or custom behavior. + * Defines injectable policies for: + * - Unknown field handling (strip, ignore, error) + * - Write value conversion (no-op, locale date formatting) * - * Analog to SelectorBuilderInterface — injectable via DynamicTableFactory constructor. + * Both follow the Strategy Pattern, injectable via DynamicTable/DynamicTableFactory constructor. * * @module types * @category Types */ +import { FieldDefinition } from './schema'; + /** * Interface for handling fields in row objects that are not defined in the table schema. * @@ -55,3 +57,53 @@ export interface UnknownFieldPolicyInterface { knownFields: string[] ): Partial[]; } + +/** + * Interface for converting field values before sending to the AppSheet API. + * + * Implementations can convert values to locale-specific formats (e.g., ISO dates + * to locale dates), normalize values, or perform any other pre-write transformation. + * + * The policy is applied AFTER validation but BEFORE sending to the API, + * ensuring only valid values are converted. + * + * Analog to UnknownFieldPolicyInterface — injectable via DynamicTable/DynamicTableFactory constructor. + * + * @category Types + * + * @example + * ```typescript + * // Use built-in policies + * import { NoOpWriteConversionPolicy, LocaleWriteConversionPolicy } from '@techdivision/appsheet'; + * + * // Or create a custom policy + * class CustomWriteConversionPolicy implements WriteConversionPolicyInterface { + * apply>( + * tableName: string, + * rows: Partial[], + * fields: Record, + * locale?: string + * ): Partial[] { + * // Custom conversion logic here + * return rows; + * } + * } + * ``` + */ +export interface WriteConversionPolicyInterface { + /** + * Convert field values in rows before sending to the AppSheet API. + * + * @param tableName - The AppSheet table name (for context) + * @param rows - The validated row objects to convert + * @param fields - Field definitions from the table schema (includes field types) + * @param locale - Optional BCP 47 locale tag for locale-aware conversion + * @returns Converted rows (may have transformed field values) + */ + apply>( + tableName: string, + rows: Partial[], + fields: Record, + locale?: string + ): Partial[]; +} diff --git a/src/utils/policies/LocaleWriteConversionPolicy.ts b/src/utils/policies/LocaleWriteConversionPolicy.ts new file mode 100644 index 0000000..3185005 --- /dev/null +++ b/src/utils/policies/LocaleWriteConversionPolicy.ts @@ -0,0 +1,148 @@ +/** + * LocaleWriteConversionPolicy - Convert ISO dates to locale format before API call + * + * Converts ISO 8601 date/datetime values to locale-specific format using + * the `getLocaleDateFormat()` function from SOSO-439. Only ISO-formatted + * values are converted; values already in locale format pass through unchanged. + * + * @module utils/policies + * @category Policies + */ + +import { FieldDefinition } from '../../types/schema'; +import { WriteConversionPolicyInterface } from '../../types/policies'; +import { getLocaleDateFormat, DateFormatInfo } from '../validators'; + +/** AppSheet field types that contain date values */ +const DATE_TYPES = new Set(['Date', 'DateTime', 'ChangeTimestamp']); + +/** ISO 8601 date pattern: YYYY-MM-DD */ +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +/** ISO 8601 datetime pattern: YYYY-MM-DDT... */ +const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T/; + +/** + * Policy that converts ISO 8601 date/datetime values to locale-specific format + * before sending to the AppSheet API. + * + * Only converts values that are in ISO format. Values already in locale format + * or other formats are passed through unchanged. This ensures safe round-trip + * behavior: data read from AppSheet (in locale format) can be sent back + * unchanged via update(). + * + * Requires a locale to be set. Without locale, acts as no-op. + * + * @category Policies + * + * @example + * ```typescript + * import { LocaleWriteConversionPolicy, DynamicTableFactory } from '@techdivision/appsheet'; + * + * const factory = new DynamicTableFactory( + * clientFactory, + * schema, + * undefined, // default unknown field policy + * new LocaleWriteConversionPolicy() // convert dates on write + * ); + * + * const table = factory.create('default', 'worklogs', 'user@example.com'); + * // table.add([{ date: '2026-03-12' }]) + * // → AppSheet receives: { date: "12.03.2026" } (if locale is de-DE) + * ``` + */ +export class LocaleWriteConversionPolicy implements WriteConversionPolicyInterface { + /** + * Converts ISO date/datetime values to locale format in all rows. + * + * @param tableName - The AppSheet table name (unused, available for subclasses) + * @param rows - The validated row objects to convert + * @param fields - Field definitions from the table schema + * @param locale - Optional BCP 47 locale tag for locale-aware conversion + * @returns New row objects with ISO dates converted to locale format + */ + apply>( + _tableName: string, + rows: Partial[], + fields: Record, + locale?: string + ): Partial[] { + // Without locale, no conversion possible + if (!locale) return rows; + + const fmt = getLocaleDateFormat(locale); + + return rows.map((row) => { + const converted = { ...row } as Record; + let changed = false; + + for (const [fieldName, fieldDef] of Object.entries(fields)) { + if (!DATE_TYPES.has(fieldDef.type)) continue; + + const value = converted[fieldName]; + if (typeof value !== 'string') continue; + + const newValue = this.convertDateValue(value, fieldDef.type, fmt); + if (newValue !== value) { + converted[fieldName] = newValue; + changed = true; + } + } + + return (changed ? converted : row) as Partial; + }); + } + + /** + * Converts a single date/datetime value from ISO to locale format. + * Returns the original value if it's not in ISO format. + */ + private convertDateValue(value: string, fieldType: string, fmt: DateFormatInfo): string { + if (fieldType === 'Date' && ISO_DATE.test(value)) { + return this.isoDateToLocale(value, fmt); + } + + if ((fieldType === 'DateTime' || fieldType === 'ChangeTimestamp') && ISO_DATETIME.test(value)) { + return this.isoDateTimeToLocale(value, fmt); + } + + // Not ISO format — pass through unchanged (e.g., round-trip from find()) + return value; + } + + /** + * Converts ISO date (YYYY-MM-DD) to locale format. + * + * @example + * isoDateToLocale("2026-03-12", deDE) → "12.03.2026" + * isoDateToLocale("2026-03-12", enUS) → "03/12/2026" + * isoDateToLocale("2026-03-12", jaJP) → "2026/03/12" + */ + private isoDateToLocale(isoDate: string, fmt: DateFormatInfo): string { + const [year, month, day] = isoDate.split('-'); + const parts: Record = { year, month, day }; + return fmt.partOrder.map((p) => parts[p]).join(fmt.separator); + } + + /** + * Converts ISO datetime (YYYY-MM-DDT...) to locale format. + * + * @example + * isoDateTimeToLocale("2026-03-12T14:30:00.000Z", deDE) → "12.03.2026 14:30:00" + * isoDateTimeToLocale("2026-03-12T14:30:00Z", enUS) → "03/12/2026 14:30:00" + */ + private isoDateTimeToLocale(isoDateTime: string, fmt: DateFormatInfo): string { + // Parse: "2026-03-12T14:30:00.000Z" or "2026-03-12T14:30:00+02:00" + const tIndex = isoDateTime.indexOf('T'); + const datePart = isoDateTime.substring(0, tIndex); + let timePart = isoDateTime.substring(tIndex + 1); + + // Strip timezone suffix (Z, +HH:MM, -HH:MM) + timePart = timePart.replace(/Z$/i, '').replace(/[+-]\d{2}:\d{2}$/, ''); + + // Strip milliseconds (.000) + timePart = timePart.replace(/\.\d+$/, ''); + + const localDate = this.isoDateToLocale(datePart, fmt); + return `${localDate} ${timePart}`; + } +} diff --git a/src/utils/policies/NoOpWriteConversionPolicy.ts b/src/utils/policies/NoOpWriteConversionPolicy.ts new file mode 100644 index 0000000..3d9e411 --- /dev/null +++ b/src/utils/policies/NoOpWriteConversionPolicy.ts @@ -0,0 +1,51 @@ +/** + * NoOpWriteConversionPolicy - Pass rows through without conversion (Default) + * + * Does not modify any field values. This is the default policy, + * maintaining backward compatibility with existing behavior. + * + * @module utils/policies + * @category Policies + */ + +import { FieldDefinition } from '../../types/schema'; +import { WriteConversionPolicyInterface } from '../../types/policies'; + +/** + * Policy that passes rows through without any value conversion. + * + * This is the **default policy** used by DynamicTable and DynamicTableFactory. + * It does not modify any field values, maintaining backward compatibility. + * + * @category Policies + * + * @example + * ```typescript + * import { NoOpWriteConversionPolicy } from '@techdivision/appsheet'; + * + * const policy = new NoOpWriteConversionPolicy(); + * const result = policy.apply('worklogs', [ + * { date: '2026-03-12', name: 'Test' } + * ], { date: { type: 'Date' }, name: { type: 'Text' } }, 'de-DE'); + * // result: [{ date: '2026-03-12', name: 'Test' }] — unchanged + * ``` + */ +export class NoOpWriteConversionPolicy implements WriteConversionPolicyInterface { + /** + * Returns rows unchanged — no value conversion is performed. + * + * @param tableName - The AppSheet table name (unused) + * @param rows - The row objects to process + * @param fields - Field definitions from the table schema (unused) + * @param locale - Optional BCP 47 locale tag (unused) + * @returns The original rows without modification + */ + apply>( + _tableName: string, + rows: Partial[], + _fields: Record, + _locale?: string + ): Partial[] { + return rows; + } +} diff --git a/src/utils/policies/index.ts b/src/utils/policies/index.ts index f341e3a..927430a 100644 --- a/src/utils/policies/index.ts +++ b/src/utils/policies/index.ts @@ -1,5 +1,8 @@ /** - * Unknown field policy implementations + * Policy implementations for DynamicTable behavior customization + * + * Unknown field policies: IgnoreUnknownFieldPolicy, StripUnknownFieldPolicy, ErrorUnknownFieldPolicy + * Write conversion policies: NoOpWriteConversionPolicy, LocaleWriteConversionPolicy * * @module utils/policies * @category Policies @@ -8,3 +11,5 @@ export * from './IgnoreUnknownFieldPolicy'; export * from './StripUnknownFieldPolicy'; export * from './ErrorUnknownFieldPolicy'; +export * from './NoOpWriteConversionPolicy'; +export * from './LocaleWriteConversionPolicy'; diff --git a/tests/client/DynamicTable.writeConversion.test.ts b/tests/client/DynamicTable.writeConversion.test.ts new file mode 100644 index 0000000..517c615 --- /dev/null +++ b/tests/client/DynamicTable.writeConversion.test.ts @@ -0,0 +1,402 @@ +/** + * Integration tests for DynamicTable with WriteConversionPolicy + * @see docs/SOSO-440/FEATURE_CONCEPT.md + */ + +import { DynamicTable } from '../../src/client/DynamicTable'; +import { AppSheetClientInterface, TableDefinition } from '../../src/types'; +import { LocaleWriteConversionPolicy } from '../../src/utils/policies'; + +/** + * Create a mock client that implements AppSheetClientInterface + */ +function createMockClient(): jest.Mocked { + return { + add: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + find: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + update: jest.fn().mockResolvedValue({ rows: [], warnings: [] }), + delete: jest.fn().mockResolvedValue({ success: true, deletedCount: 0, warnings: [] }), + findAll: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + addOne: jest.fn().mockResolvedValue({}), + updateOne: jest.fn().mockResolvedValue({}), + deleteOne: jest.fn().mockResolvedValue(true), + getTable: jest.fn().mockReturnValue({ + tableName: 'test', + keyField: 'id', + fields: { id: { type: 'Text', required: true } }, + }), + }; +} + +describe('DynamicTable with WriteConversionPolicy', () => { + let mockClient: jest.Mocked; + + const tableDef: TableDefinition = { + tableName: 'extract_worklog', + keyField: 'id', + locale: 'de-DE', + fields: { + id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + created_at: { type: 'DateTime', required: false }, + description: { type: 'Text', required: false }, + }, + }; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + // ============================================ + // add() with LocaleWriteConversionPolicy + // ============================================ + + describe('add() with LocaleWriteConversionPolicy', () => { + it('should convert ISO date to locale format before sending to client', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, // default unknown field policy + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12', description: 'Test' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ id: '1', date: '12.03.2026', description: 'Test' })], + }) + ); + }); + + it('should convert ISO datetime to locale format before sending to client', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12', created_at: '2026-03-12T14:30:00.000Z' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [ + expect.objectContaining({ + date: '12.03.2026', + created_at: '12.03.2026 14:30:00', + }), + ], + }) + ); + }); + + it('should convert ISO dates with en-US locale', async () => { + const enUsDef: TableDefinition = { + ...tableDef, + locale: 'en-US', + }; + const table = new DynamicTable( + mockClient, + enUsDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '03/12/2026' })], + }) + ); + }); + + it('should not convert non-date fields', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12', description: '2026-03-12 is a date' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [ + expect.objectContaining({ + description: '2026-03-12 is a date', // Text field unchanged + date: '12.03.2026', // Date field converted + }), + ], + }) + ); + }); + }); + + // ============================================ + // update() with LocaleWriteConversionPolicy + // ============================================ + + describe('update() with LocaleWriteConversionPolicy', () => { + it('should convert ISO date to locale format before sending to client', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.update([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ id: '1', date: '12.03.2026' })], + }) + ); + }); + + it('should convert ISO datetime to locale format before sending to client', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.update([{ id: '1', created_at: '2026-03-12T14:30:00Z' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ created_at: '12.03.2026 14:30:00' })], + }) + ); + }); + }); + + // ============================================ + // delete() — NO write conversion + // ============================================ + + describe('delete() does NOT apply write conversion', () => { + it('should pass keys through unchanged', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.delete([{ id: '1' }]); + + // delete() should pass keys through without conversion + expect(mockClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [{ id: '1' }], + }) + ); + }); + }); + + // ============================================ + // Default behavior (NoOp, backward compatible) + // ============================================ + + describe('default NoOp behavior (backward compatible)', () => { + it('should send ISO dates unchanged when no policy is provided', async () => { + const table = new DynamicTable(mockClient, tableDef); + // No writeConversionPolicy → default NoOp + + await table.add([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '2026-03-12' })], + }) + ); + }); + + it('should send ISO datetimes unchanged when no policy is provided', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await table.add([{ id: '1', date: '2026-03-12', created_at: '2026-03-12T14:30:00.000Z' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [ + expect.objectContaining({ + date: '2026-03-12', + created_at: '2026-03-12T14:30:00.000Z', + }), + ], + }) + ); + }); + }); + + // ============================================ + // Round-trip: find() → update() with locale dates + // ============================================ + + describe('Round-trip: find() → update() with locale dates', () => { + it('should pass through locale-formatted dates from find() unchanged in update()', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + // Simulate: find() returned locale-formatted data from AppSheet + // User modifies only description, date stays as-is from AppSheet + await table.update([ + { id: '1', date: '12.03.2026', created_at: '12.03.2026 14:30:00', description: 'Updated' }, + ]); + + // Locale dates should pass through unchanged (not double-converted) + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [ + expect.objectContaining({ + date: '12.03.2026', // NOT re-converted + created_at: '12.03.2026 14:30:00', // NOT re-converted + description: 'Updated', + }), + ], + }) + ); + }); + + it('should handle mixed ISO and locale dates in same update', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + // date: from AppSheet (locale), created_at: new ISO value from consumer + await table.update([{ id: '1', date: '12.03.2026', created_at: '2026-03-12T18:00:00Z' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [ + expect.objectContaining({ + date: '12.03.2026', // Already locale → pass through + created_at: '12.03.2026 18:00:00', // ISO → converted + }), + ], + }) + ); + }); + }); + + // ============================================ + // Without locale (no conversion possible) + // ============================================ + + describe('without locale (no conversion)', () => { + it('should act as no-op when table has no locale', async () => { + const noLocaleDef: TableDefinition = { + tableName: 'extract_worklog', + keyField: 'id', + // no locale + fields: { + id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + }, + }; + const table = new DynamicTable( + mockClient, + noLocaleDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12' }]); + + // Without locale, LocaleWriteConversionPolicy acts as no-op + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '2026-03-12' })], + }) + ); + }); + }); + + // ============================================ + // ChangeTimestamp field + // ============================================ + + describe('ChangeTimestamp field conversion', () => { + it('should convert ChangeTimestamp ISO values like DateTime', async () => { + const changeTimestampDef: TableDefinition = { + tableName: 'audit_log', + keyField: 'id', + locale: 'de-DE', + fields: { + id: { type: 'Text', required: true }, + modified_at: { type: 'ChangeTimestamp', required: false }, + }, + }; + const table = new DynamicTable( + mockClient, + changeTimestampDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.update([{ id: '1', modified_at: '2026-03-12T14:30:00Z' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ modified_at: '12.03.2026 14:30:00' })], + }) + ); + }); + }); + + // ============================================ + // Locale in properties still sent correctly + // ============================================ + + describe('Locale in properties with write conversion', () => { + it('should send both converted rows AND Locale property on add()', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.add([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.add).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '12.03.2026' })], + properties: expect.objectContaining({ Locale: 'de-DE' }), + }) + ); + }); + + it('should send both converted rows AND Locale property on update()', async () => { + const table = new DynamicTable( + mockClient, + tableDef, + undefined, + new LocaleWriteConversionPolicy() + ); + + await table.update([{ id: '1', date: '2026-03-12' }]); + + expect(mockClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + rows: [expect.objectContaining({ date: '12.03.2026' })], + properties: expect.objectContaining({ Locale: 'de-DE' }), + }) + ); + }); + }); +}); diff --git a/tests/utils/policies/WriteConversionPolicies.test.ts b/tests/utils/policies/WriteConversionPolicies.test.ts new file mode 100644 index 0000000..bbf2bc5 --- /dev/null +++ b/tests/utils/policies/WriteConversionPolicies.test.ts @@ -0,0 +1,311 @@ +/** + * Unit tests for WriteConversionPolicy implementations + * + * Tests both built-in policies: + * - NoOpWriteConversionPolicy (pass-through, default) + * - LocaleWriteConversionPolicy (ISO → locale date conversion) + * + * @see docs/SOSO-440/FEATURE_CONCEPT.md + */ + +import { NoOpWriteConversionPolicy } from '../../../src/utils/policies/NoOpWriteConversionPolicy'; +import { LocaleWriteConversionPolicy } from '../../../src/utils/policies/LocaleWriteConversionPolicy'; +import { FieldDefinition } from '../../../src/types'; + +// ============================================ +// Shared test fixtures +// ============================================ + +const dateFields: Record = { + date: { type: 'Date' }, + name: { type: 'Text' }, +}; + +const dateTimeFields: Record = { + created: { type: 'DateTime' }, + name: { type: 'Text' }, +}; + +const changeTimestampFields: Record = { + modified: { type: 'ChangeTimestamp' }, +}; + +const mixedFields: Record = { + id: { type: 'Text', required: true }, + date: { type: 'Date' }, + created: { type: 'DateTime' }, + modified: { type: 'ChangeTimestamp' }, + name: { type: 'Text' }, + count: { type: 'Number' }, +}; + +// ============================================ +// NoOpWriteConversionPolicy +// ============================================ + +describe('NoOpWriteConversionPolicy', () => { + const policy = new NoOpWriteConversionPolicy(); + + it('should return rows unchanged with locale', () => { + const rows = [{ date: '2026-03-12', name: 'Test' }]; + const result = policy.apply('table', rows, dateFields, 'de-DE'); + expect(result).toEqual(rows); + }); + + it('should return rows unchanged without locale', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('table', rows, dateFields); + expect(result).toEqual(rows); + }); + + it('should return the exact same array reference', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('table', rows, dateFields, 'de-DE'); + expect(result).toBe(rows); + }); + + it('should handle empty rows array', () => { + const result = policy.apply('table', [], dateFields, 'de-DE'); + expect(result).toEqual([]); + }); +}); + +// ============================================ +// LocaleWriteConversionPolicy — Date +// ============================================ + +describe('LocaleWriteConversionPolicy', () => { + const policy = new LocaleWriteConversionPolicy(); + + describe('Date conversion', () => { + it('should convert ISO date to de-DE format', () => { + const rows = [{ date: '2026-03-12', name: 'Test' }]; + const result = policy.apply('t', rows, dateFields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + expect(result[0].name).toBe('Test'); // Non-date fields unchanged + }); + + it('should convert ISO date to en-US format', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, dateFields, 'en-US'); + expect(result[0].date).toBe('03/12/2026'); + }); + + it('should convert ISO date to ja-JP format', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, dateFields, 'ja-JP'); + expect(result[0].date).toBe('2026/03/12'); + }); + + it('should pass through non-ISO dates unchanged (round-trip)', () => { + const rows = [{ date: '12.03.2026' }]; // Already in de-DE + const result = policy.apply('t', rows, dateFields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + }); + + it('should pass through en-US locale dates unchanged (round-trip)', () => { + const rows = [{ date: '03/11/2026' }]; // Already in en-US + const result = policy.apply('t', rows, dateFields, 'en-US'); + expect(result[0].date).toBe('03/11/2026'); + }); + + it('should pass through ja-JP locale dates unchanged (round-trip)', () => { + const rows = [{ date: '2026/03/11' }]; // Already in ja-JP + const result = policy.apply('t', rows, dateFields, 'ja-JP'); + expect(result[0].date).toBe('2026/03/11'); + }); + + it('should not mutate original rows', () => { + const rows = [{ date: '2026-03-12', name: 'Test' }]; + const original = { ...rows[0] }; + policy.apply('t', rows, dateFields, 'de-DE'); + expect(rows[0]).toEqual(original); + }); + }); + + // ============================================ + // DateTime conversion + // ============================================ + + describe('DateTime conversion', () => { + it('should convert ISO datetime with Z timezone to de-DE', () => { + const rows = [{ created: '2026-03-12T14:30:00.000Z' }]; + const result = policy.apply('t', rows, dateTimeFields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should convert ISO datetime with Z timezone to en-US', () => { + const rows = [{ created: '2026-03-12T14:30:00Z' }]; + const result = policy.apply('t', rows, dateTimeFields, 'en-US'); + expect(result[0].created).toBe('03/12/2026 14:30:00'); + }); + + it('should convert ISO datetime with offset timezone', () => { + const rows = [{ created: '2026-03-12T14:30:00+02:00' }]; + const result = policy.apply('t', rows, dateTimeFields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should convert ISO datetime without timezone', () => { + const rows = [{ created: '2026-03-12T14:30:00' }]; + const result = policy.apply('t', rows, dateTimeFields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should convert ISO datetime with milliseconds', () => { + const rows = [{ created: '2026-03-12T14:30:00.123Z' }]; + const result = policy.apply('t', rows, dateTimeFields, 'en-US'); + expect(result[0].created).toBe('03/12/2026 14:30:00'); + }); + + it('should pass through locale datetimes unchanged (round-trip de-DE)', () => { + const rows = [{ created: '12.03.2026 14:30:00' }]; + const result = policy.apply('t', rows, dateTimeFields, 'de-DE'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + }); + + it('should pass through locale datetimes unchanged (round-trip en-US)', () => { + const rows = [{ created: '03/11/2026 21:51:24' }]; + const result = policy.apply('t', rows, dateTimeFields, 'en-US'); + expect(result[0].created).toBe('03/11/2026 21:51:24'); + }); + }); + + // ============================================ + // ChangeTimestamp conversion + // ============================================ + + describe('ChangeTimestamp conversion', () => { + it('should convert ChangeTimestamp like DateTime', () => { + const rows = [{ modified: '2026-03-12T14:30:00Z' }]; + const result = policy.apply('t', rows, changeTimestampFields, 'de-DE'); + expect(result[0].modified).toBe('12.03.2026 14:30:00'); + }); + + it('should pass through locale ChangeTimestamp unchanged (round-trip)', () => { + const rows = [{ modified: '12.03.2026 14:30:00' }]; + const result = policy.apply('t', rows, changeTimestampFields, 'de-DE'); + expect(result[0].modified).toBe('12.03.2026 14:30:00'); + }); + }); + + // ============================================ + // Without locale (no-op behavior) + // ============================================ + + describe('without locale', () => { + it('should act as no-op without locale', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, dateFields); + expect(result[0].date).toBe('2026-03-12'); + }); + + it('should act as no-op with undefined locale', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, dateFields, undefined); + expect(result[0].date).toBe('2026-03-12'); + }); + + it('should return the exact same array reference without locale', () => { + const rows = [{ date: '2026-03-12' }]; + const result = policy.apply('t', rows, dateFields); + expect(result).toBe(rows); + }); + }); + + // ============================================ + // Non-date fields + // ============================================ + + describe('non-date fields', () => { + it('should not touch Text fields', () => { + const fields: Record = { + name: { type: 'Text' }, + count: { type: 'Number' }, + }; + const rows = [{ name: 'Test', count: 42 }]; + const result = policy.apply('t', rows, fields, 'de-DE'); + expect(result).toEqual(rows); + }); + + it('should handle null/undefined date values', () => { + const rows = [{ date: undefined }, { date: null }]; + const result = policy.apply('t', rows as any, dateFields, 'de-DE'); + expect(result[0].date).toBeUndefined(); + expect(result[1].date).toBeNull(); + }); + + it('should handle numeric date values (not string)', () => { + const rows = [{ date: 12345 }]; + const result = policy.apply('t', rows as any, dateFields, 'de-DE'); + expect(result[0].date).toBe(12345); // Not a string, pass through + }); + }); + + // ============================================ + // Multiple rows + // ============================================ + + describe('multiple rows', () => { + it('should convert all ISO rows and pass through locale rows', () => { + const rows = [ + { date: '2026-03-12' }, // ISO → convert + { date: '2026-12-25' }, // ISO → convert + { date: '12.03.2026' }, // Already locale → pass through + ]; + const result = policy.apply('t', rows, dateFields, 'de-DE'); + expect(result[0].date).toBe('12.03.2026'); + expect(result[1].date).toBe('25.12.2026'); + expect(result[2].date).toBe('12.03.2026'); + }); + }); + + // ============================================ + // Mixed field types + // ============================================ + + describe('mixed field types', () => { + it('should convert only date/datetime fields in a mixed row', () => { + const rows = [ + { + id: '1', + date: '2026-03-12', + created: '2026-03-12T14:30:00Z', + modified: '2026-03-12T09:00:00.000Z', + name: 'Test', + count: 42, + }, + ]; + const result = policy.apply('t', rows, mixedFields, 'de-DE'); + expect(result[0].id).toBe('1'); + expect(result[0].date).toBe('12.03.2026'); + expect(result[0].created).toBe('12.03.2026 14:30:00'); + expect(result[0].modified).toBe('12.03.2026 09:00:00'); + expect(result[0].name).toBe('Test'); + expect(result[0].count).toBe(42); + }); + }); + + // ============================================ + // Empty rows + // ============================================ + + describe('edge cases', () => { + it('should handle empty rows array', () => { + const result = policy.apply('t', [], dateFields, 'de-DE'); + expect(result).toEqual([]); + }); + + it('should handle rows with no matching date fields', () => { + const rows = [{ name: 'Test' }]; + const result = policy.apply('t', rows, dateFields, 'de-DE'); + expect(result[0].name).toBe('Test'); + }); + + it('should handle empty row objects', () => { + const rows = [{}]; + const result = policy.apply('t', rows, dateFields, 'de-DE'); + expect(result).toEqual([{}]); + }); + }); +});