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
62 changes: 52 additions & 10 deletions src/client/DynamicTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* @category Client
*/

import { AppSheetClientInterface, TableDefinition } from '../types';
import { AppSheetClientInterface, TableDefinition, UnknownFieldPolicyInterface } from '../types';
import { AppSheetTypeValidator } from '../utils/validators';
import { StripUnknownFieldPolicy } from '../utils/policies';

/**
* Table client with schema-based operations and runtime validation.
Expand Down Expand Up @@ -34,10 +35,22 @@ import { AppSheetTypeValidator } from '../utils/validators';
* ```
*/
export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
private readonly unknownFieldPolicy: UnknownFieldPolicyInterface;

/**
* 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)
*/
constructor(
private client: AppSheetClientInterface,
private definition: TableDefinition
) {}
private definition: TableDefinition,
unknownFieldPolicy?: UnknownFieldPolicyInterface
) {
this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy();
}

/**
* Find all rows in the table.
Expand Down Expand Up @@ -145,12 +158,20 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
* ```
*/
async add(rows: Partial<T>[]): Promise<T[]> {
// Apply unknown field policy before validation
const knownFields = Object.keys(this.definition.fields);
const processedRows = this.unknownFieldPolicy.apply<T>(
this.definition.tableName,
rows,
knownFields
);

// Validate rows
this.validateRows(rows);
this.validateRows(processedRows);

const result = await this.client.add<T>({
tableName: this.definition.tableName,
rows: rows as T[],
rows: processedRows as T[],
});
return result.rows;
}
Expand Down Expand Up @@ -180,12 +201,20 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
* ```
*/
async update(rows: Partial<T>[]): Promise<T[]> {
// Apply unknown field policy before validation
const knownFields = Object.keys(this.definition.fields);
const processedRows = this.unknownFieldPolicy.apply<T>(
this.definition.tableName,
rows,
knownFields
);

// Validate rows
this.validateRows(rows, false);
this.validateRows(processedRows, false);

const result = await this.client.update<T>({
tableName: this.definition.tableName,
rows: rows as T[],
rows: processedRows as T[],
});
return result.rows;
}
Expand Down Expand Up @@ -214,9 +243,17 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {
* ```
*/
async delete(keys: Partial<T>[]): Promise<boolean> {
// Apply unknown field policy to delete keys too
const knownFields = Object.keys(this.definition.fields);
const processedKeys = this.unknownFieldPolicy.apply<T>(
this.definition.tableName,
keys,
knownFields
);

await this.client.delete({
tableName: this.definition.tableName,
rows: keys,
rows: processedKeys,
});
return true;
}
Expand Down Expand Up @@ -315,10 +352,15 @@ export class DynamicTable<T extends Record<string, any> = Record<string, any>> {

// Enum/EnumList validation
if (fieldDef.allowedValues) {
AppSheetTypeValidator.validateEnum(fieldName, fieldType, fieldDef.allowedValues, value, i);
AppSheetTypeValidator.validateEnum(
fieldName,
fieldType,
fieldDef.allowedValues,
value,
i
);
}
}
}
}

}
31 changes: 24 additions & 7 deletions src/client/DynamicTableFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
* @category Client
*/

import { DynamicTableFactoryInterface, AppSheetClientFactoryInterface, SchemaConfig } from '../types';
import {
DynamicTableFactoryInterface,
AppSheetClientFactoryInterface,
SchemaConfig,
UnknownFieldPolicyInterface,
} from '../types';
import { StripUnknownFieldPolicy } from '../utils/policies';
import { DynamicTable } from './DynamicTable';

/**
Expand All @@ -24,10 +30,13 @@ import { DynamicTable } from './DynamicTable';
*
* @example
* ```typescript
* // Create factory with client factory and schema
* // Create factory with client factory and schema (default: StripUnknownFieldPolicy)
* 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 table instances
* const usersTable = tableFactory.create<User>('worklog', 'users', 'user@example.com');
* const users = await usersTable.findAll();
Expand All @@ -39,16 +48,22 @@ import { DynamicTable } from './DynamicTable';
* ```
*/
export class DynamicTableFactory implements DynamicTableFactoryInterface {
private readonly unknownFieldPolicy: UnknownFieldPolicyInterface;

/**
* 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)
*/
constructor(
private readonly clientFactory: AppSheetClientFactoryInterface,
private readonly schema: SchemaConfig
) {}
private readonly schema: SchemaConfig,
unknownFieldPolicy?: UnknownFieldPolicyInterface
) {
this.unknownFieldPolicy = unknownFieldPolicy ?? new StripUnknownFieldPolicy();
}

/**
* Create a DynamicTable instance for a specific connection and table.
Expand All @@ -75,7 +90,9 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface {
const connectionDef = this.schema.connections[connectionName];
if (!connectionDef) {
const available = Object.keys(this.schema.connections).join(', ') || 'none';
throw new Error(`Connection "${connectionName}" not found. Available connections: ${available}`);
throw new Error(
`Connection "${connectionName}" not found. Available connections: ${available}`
);
}

// Create client using factory
Expand All @@ -84,7 +101,7 @@ export class DynamicTableFactory implements DynamicTableFactoryInterface {
// Get table definition (will throw if not found)
const tableDef = client.getTable(tableName);

// Create and return DynamicTable
return new DynamicTable<T>(client, tableDef);
// Create and return DynamicTable with injected policy
return new DynamicTable<T>(client, tableDef, this.unknownFieldPolicy);
}
}
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ export * from './factories';

// Selector builder interface
export * from './selector';

// Policy interfaces
export * from './policies';
57 changes: 57 additions & 0 deletions src/types/policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Unknown Field Policy Interface
*
* 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.
*
* Analog to SelectorBuilderInterface — injectable via DynamicTableFactory constructor.
*
* @module types
* @category Types
*/

/**
* Interface for handling fields in row objects that are not defined in the table schema.
*
* Implementations decide what happens when a row contains fields that are not
* in the schema: ignore them, strip them, throw an error, or custom behavior.
*
* Analog to SelectorBuilderInterface — injectable via DynamicTableFactory constructor.
*
* @category Types
*
* @example
* ```typescript
* // Use built-in policies
* import { StripUnknownFieldPolicy, ErrorUnknownFieldPolicy } from '@techdivision/appsheet';
*
* // Or create a custom policy
* class LoggingStripPolicy implements UnknownFieldPolicyInterface {
* apply<T extends Record<string, any>>(
* tableName: string,
* rows: Partial<T>[],
* knownFields: string[]
* ): Partial<T>[] {
* // Custom logging logic here
* return new StripUnknownFieldPolicy().apply(tableName, rows, knownFields);
* }
* }
* ```
*/
export interface UnknownFieldPolicyInterface {
/**
* Process rows and handle any fields not defined in the table schema.
*
* @param tableName - The AppSheet table name (for error messages)
* @param rows - The row objects to process
* @param knownFields - Array of field names defined in the table schema
* @returns Processed rows (may be modified, filtered, or unchanged)
* @throws {ValidationError} If the policy rejects unknown fields (e.g. ErrorUnknownFieldPolicy)
*/
apply<T extends Record<string, any>>(
tableName: string,
rows: Partial<T>[],
knownFields: string[]
): Partial<T>[];
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './SchemaLoader';
export * from './SchemaManager';
export * from './validators';
export * from './SelectorBuilder';
export * from './policies';
67 changes: 67 additions & 0 deletions src/utils/policies/ErrorUnknownFieldPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* ErrorUnknownFieldPolicy - Throw ValidationError for unknown fields
*
* Throws a ValidationError when rows contain fields not defined in the table schema.
* Use this for strict validation in CI/CD pipelines or development environments.
*
* @module utils/policies
* @category Policies
*/

import { UnknownFieldPolicyInterface } from '../../types/policies';
import { ValidationError } from '../../types/errors';

/**
* Policy that throws a ValidationError when unknown fields are detected.
*
* This is the strictest policy. Any field in a row that is not defined in the
* table schema will cause a ValidationError to be thrown immediately.
*
* @category Policies
*
* @example
* ```typescript
* import { ErrorUnknownFieldPolicy, DynamicTableFactory } from '@techdivision/appsheet';
*
* // Strict mode for CI/CD
* const factory = new DynamicTableFactory(
* clientFactory,
* schema,
* new ErrorUnknownFieldPolicy()
* );
*
* // Will throw: Unknown fields in table "solution" (row 0): id
* await table.add([{ solution_id: '1', id: '1' }]);
* ```
*/
export class ErrorUnknownFieldPolicy implements UnknownFieldPolicyInterface {
/**
* Validates that all fields in each row are defined in the schema.
* Throws ValidationError if any unknown fields are found.
*
* @param tableName - The AppSheet table name (used in error messages)
* @param rows - The row objects to validate
* @param knownFields - Array of field names defined in the table schema
* @returns The original rows if no unknown fields are found
* @throws {ValidationError} If any row contains fields not in knownFields
*/
apply<T extends Record<string, any>>(
tableName: string,
rows: Partial<T>[],
knownFields: string[]
): Partial<T>[] {
const knownSet = new Set(knownFields);
for (let i = 0; i < rows.length; i++) {
const unknownFields = Object.keys(rows[i]).filter((key) => !knownSet.has(key));
if (unknownFields.length > 0) {
throw new ValidationError(
`Unknown fields in table "${tableName}" (row ${i}): ${unknownFields.join(', ')}. ` +
`These fields are not defined in the schema. ` +
`Remove them or update the schema to include them.`,
{ tableName, unknownFields, rowIndex: i }
);
}
}
return rows;
}
}
48 changes: 48 additions & 0 deletions src/utils/policies/IgnoreUnknownFieldPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* IgnoreUnknownFieldPolicy - Pass rows through unchanged
*
* Does not modify rows. Unknown fields are passed to the AppSheet API as-is.
* Use this for legacy code or migration scenarios where unknown fields are expected.
*
* @module utils/policies
* @category Policies
*/

import { UnknownFieldPolicyInterface } from '../../types/policies';

/**
* Policy that ignores unknown fields and passes rows through unchanged.
*
* Unknown fields are sent to the AppSheet API as-is. This is the least safe
* option but useful for legacy code or migration scenarios.
*
* @category Policies
*
* @example
* ```typescript
* import { IgnoreUnknownFieldPolicy, DynamicTableFactory } from '@techdivision/appsheet';
*
* const factory = new DynamicTableFactory(
* clientFactory,
* schema,
* new IgnoreUnknownFieldPolicy()
* );
* ```
*/
export class IgnoreUnknownFieldPolicy implements UnknownFieldPolicyInterface {
/**
* Returns rows unchanged — unknown fields are not modified.
*
* @param tableName - The AppSheet table name (unused)
* @param rows - The row objects to process
* @param knownFields - Array of known field names (unused)
* @returns The original rows without modification
*/
apply<T extends Record<string, any>>(
_tableName: string,
rows: Partial<T>[],
_knownFields: string[]
): Partial<T>[] {
return rows;
}
}
Loading
Loading