Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
913 changes: 913 additions & 0 deletions docs/SOSO-440/FEATURE_CONCEPT.md

Large diffs are not rendered by default.

684 changes: 684 additions & 0 deletions docs/SOSO-446/FEATURE_CONCEPT.md

Large diffs are not rendered by default.

35 changes: 30 additions & 5 deletions src/client/DynamicTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -36,20 +41,24 @@ import { StripUnknownFieldPolicy } from '../utils/policies';
*/
export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
private readonly unknownFieldPolicy: UnknownFieldPolicyInterface;
private readonly writeConversionPolicy: WriteConversionPolicyInterface;

/**
* Creates a new DynamicTable instance.
*
* @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();
}

/**
Expand Down Expand Up @@ -169,9 +178,17 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
// Validate rows
this.validateRows(processedRows);

// Apply write conversion (e.g., ISO dates → locale format)
const convertedRows = this.writeConversionPolicy.apply<T>(
this.definition.tableName,
processedRows,
this.definition.fields,
this.definition.locale
);

const result = await this.client.add<T>({
tableName: this.definition.tableName,
rows: processedRows as T[],
rows: convertedRows as T[],
properties: this.definition.locale ? { Locale: this.definition.locale } : undefined,
});
return result.rows;
Expand Down Expand Up @@ -213,9 +230,17 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
// Validate rows
this.validateRows(processedRows, false);

// Apply write conversion (e.g., ISO dates → locale format)
const convertedRows = this.writeConversionPolicy.apply<T>(
this.definition.tableName,
processedRows,
this.definition.fields,
this.definition.locale
);

const result = await this.client.update<T>({
tableName: this.definition.tableName,
rows: processedRows as T[],
rows: convertedRows as T[],
properties: this.definition.locale ? { Locale: this.definition.locale } : undefined,
});
return result.rows;
Expand Down
23 changes: 18 additions & 5 deletions src/client/DynamicTableFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<User>('worklog', 'users', 'user@example.com');
* const users = await usersTable.findAll();
Expand All @@ -49,20 +53,24 @@ import { DynamicTable } from './DynamicTable';
*/
export class DynamicTableFactory implements DynamicTableFactoryInterface {
private readonly unknownFieldPolicy: UnknownFieldPolicyInterface;
private readonly writeConversionPolicy: WriteConversionPolicyInterface;

/**
* Creates a new DynamicTableFactory.
*
* @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();
}

/**
Expand Down Expand Up @@ -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<T>(client, resolvedTableDef, this.unknownFieldPolicy);
// Create and return DynamicTable with injected policies
return new DynamicTable<T>(
client,
resolvedTableDef,
this.unknownFieldPolicy,
this.writeConversionPolicy
);
}
}
62 changes: 57 additions & 5 deletions src/types/policies.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -55,3 +57,53 @@ export interface UnknownFieldPolicyInterface {
knownFields: string[]
): Partial<T>[];
}

/**
* 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<T extends Record<string, any>>(
* tableName: string,
* rows: Partial<T>[],
* fields: Record<string, FieldDefinition>,
* locale?: string
* ): Partial<T>[] {
* // 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<T extends Record<string, any>>(
tableName: string,
rows: Partial<T>[],
fields: Record<string, FieldDefinition>,
locale?: string
): Partial<T>[];
}
148 changes: 148 additions & 0 deletions src/utils/policies/LocaleWriteConversionPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, any>>(
_tableName: string,
rows: Partial<T>[],
fields: Record<string, FieldDefinition>,
locale?: string
): Partial<T>[] {
// Without locale, no conversion possible
if (!locale) return rows;

const fmt = getLocaleDateFormat(locale);

return rows.map((row) => {
const converted = { ...row } as Record<string, any>;
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<T>;
});
}

/**
* 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<string, string> = { 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}`;
}
}
Loading
Loading