diff --git a/CLAUDE.md b/CLAUDE.md index 146e6ed..2b9b6f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,10 +97,18 @@ npx appsheet inspect --help # After npm install (uses bin entry) - Ensures type safety and allows swapping implementations in tests **DynamicTable** (`src/client/DynamicTable.ts`) -- Schema-aware table client with runtime validation -- Validates field types, required fields, and enum values based on TableDefinition +- Schema-aware table client with comprehensive AppSheet field type validation +- Validates all 27 AppSheet field types (Email, URL, Phone, Enum, EnumList, etc.) +- Format validation (email addresses, URLs, phone numbers, dates, percentages) +- Required field validation and enum value constraints - Created by SchemaManager, not instantiated directly +**Validators** (`src/utils/validators/`) +- **BaseTypeValidator**: JavaScript primitive type validation (string, number, boolean, array) +- **FormatValidator**: Format-specific validation (Email, URL, Phone, Date, DateTime, Percent) +- **AppSheetTypeValidator**: Main orchestrator for AppSheet field type validation +- Modular, reusable validation logic across the codebase + **SchemaLoader** (`src/utils/SchemaLoader.ts`) - Loads schema from YAML/JSON files - Resolves environment variables with `${VAR_NAME}` syntax @@ -122,8 +130,11 @@ npx appsheet inspect --help # After npm install (uses bin entry) ### CLI Tool **SchemaInspector** (`src/cli/SchemaInspector.ts`) -- Introspects AppSheet tables by fetching sample data -- Infers field types from actual data +- Introspects AppSheet tables by analyzing up to 100 rows +- Automatically detects all 27 AppSheet field types from actual data +- Smart Enum detection: Identifies enum fields based on unique value ratio +- Extracts `allowedValues` for Enum/EnumList fields automatically +- Pattern detection for Email, URL, Phone, Date, DateTime, Percent - Guesses key fields (looks for: id, key, ID, Key, _RowNumber) - Converts table names to schema names (e.g., "extract_user" → "users") @@ -139,21 +150,35 @@ CLI binary name: `appsheet` (defined in package.json bin field) ## Key Design Patterns -### Schema Structure +### Schema Structure (v2.0.0) ```yaml connections: : appId: ${ENV_VAR} applicationAccessKey: ${ENV_VAR} - runAsUserEmail: user@example.com # Optional: global user for all operations in this connection + runAsUserEmail: user@example.com # Optional: global user for all operations tables: : tableName: keyField: fields: - : # or full FieldDefinition object + : + type: # Required: Text, Email, Number, Enum, etc. + required: # Optional: default false + allowedValues: [...] # Optional: for Enum/EnumList + description: # Optional ``` +**AppSheet Field Types (27 total):** +- **Core**: Text, Number, Date, DateTime, Time, Duration, YesNo +- **Specialized Text**: Name, Email, URL, Phone, Address +- **Specialized Numbers**: Decimal, Percent, Price +- **Selection**: Enum, EnumList +- **Media**: Image, File, Drawing, Signature +- **Tracking**: ChangeCounter, ChangeTimestamp, ChangeLocation +- **References**: Ref, RefList +- **Special**: Color, Show + ### Two Usage Patterns **Pattern 1: Direct Client** @@ -174,11 +199,48 @@ const table = db.table('connection', 'tableName'); await table.findAll(); ``` +### Validation Examples + +**Schema Definition with AppSheet Types:** +```yaml +fields: + email: + type: Email + required: true + status: + type: Enum + required: true + allowedValues: ["Active", "Inactive", "Pending"] + tags: + type: EnumList + allowedValues: ["JavaScript", "TypeScript", "React"] + discount: + type: Percent + required: false + website: + type: URL +``` + +**Validation Errors:** +```typescript +// Invalid email format +await table.add([{ email: 'invalid' }]); +// ❌ ValidationError: Field "email" must be a valid email address + +// Invalid enum value +await table.add([{ status: 'Unknown' }]); +// ❌ ValidationError: Field "status" must be one of: Active, Inactive, Pending + +// Invalid percentage +await table.add([{ discount: 1.5 }]); +// ❌ ValidationError: Field "discount" must be between 0.00 and 1.00 +``` + ### Error Handling All errors extend `AppSheetError` with specific subtypes: - `AuthenticationError` (401/403) -- `ValidationError` (400) +- `ValidationError` (400) - Now includes field-level validation errors - `NotFoundError` (404) - `RateLimitError` (429) - `NetworkError` (no response) @@ -216,6 +278,44 @@ Retry logic applies to network errors and 5xx server errors (max 3 attempts by d **Note**: The AppSheet API may return responses in either format. The AppSheetClient automatically normalizes both formats to the standard `{ Rows: [...], Warnings?: [...] }` structure for consistent handling. +## Breaking Changes (v2.0.0) + +**⚠️ IMPORTANT**: Version 2.0.0 introduces breaking changes. See MIGRATION.md for upgrade guide. + +### Removed Features +- ❌ Old generic types (`'string'`, `'number'`, `'boolean'`, `'date'`, `'array'`, `'object'`) are no longer supported +- ❌ Shorthand string format for field definitions (`"email": "string"`) is no longer supported +- ❌ `enum` property renamed to `allowedValues` + +### New Requirements +- ✅ All fields must use full FieldDefinition object with `type` property +- ✅ Only AppSheet-specific types are supported (Text, Email, Number, etc.) +- ✅ Schema validation is stricter and more comprehensive + +### Migration Example +```yaml +# ❌ Old schema (v1.x) - NO LONGER WORKS +fields: + email: string + age: number + status: + type: string + enum: ["Active", "Inactive"] + +# ✅ New schema (v2.0.0) +fields: + email: + type: Email + required: true + age: + type: Number + required: false + status: + type: Enum + required: true + allowedValues: ["Active", "Inactive"] +``` + ## Documentation All public APIs use TSDoc comments with: diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..c9ec440 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,397 @@ +# Migration Guide: v1.x → v2.0.0 + +This guide helps you upgrade from version 1.x to 2.0.0 of the AppSheet library. + +## Overview + +Version 2.0.0 introduces **breaking changes** to provide better type safety and validation through AppSheet-specific field types. The old generic TypeScript types (`string`, `number`, etc.) have been replaced with 27 AppSheet field types that match the actual AppSheet column types. + +## What's Changed + +### 1. Field Type System + +**Old (v1.x)**: Generic TypeScript types +```typescript +type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object'; +``` + +**New (v2.0.0)**: AppSheet-specific types +```typescript +type AppSheetFieldType = + | 'Text' | 'Email' | 'URL' | 'Phone' + | 'Number' | 'Decimal' | 'Percent' | 'Price' + | 'Date' | 'DateTime' | 'Time' | 'Duration' + | 'YesNo' | 'Enum' | 'EnumList' + // ... and 12 more types +``` + +### 2. Schema Format + +**Old (v1.x)**: Mixed formats allowed +```yaml +fields: + email: string # Shorthand string + age: number # Shorthand string + status: # Full object + type: string + enum: ["Active", "Inactive"] +``` + +**New (v2.0.0)**: Only full object format +```yaml +fields: + email: + type: Email + required: true + age: + type: Number + required: false + status: + type: Enum + required: true + allowedValues: ["Active", "Inactive"] +``` + +### 3. Property Renames + +| Old Property | New Property | Notes | +|--------------|--------------|-------| +| `enum` | `allowedValues` | More descriptive name | + +## Migration Steps + +### Step 1: Update Schema Files + +#### Generic Types → AppSheet Types + +```yaml +# Before (v1.x) +fields: + name: string + email: string + age: number + active: boolean + createdAt: date + tags: array + +# After (v2.0.0) +fields: + name: + type: Text + required: false + email: + type: Email + required: true + age: + type: Number + required: false + active: + type: YesNo + required: false + createdAt: + type: DateTime + required: false + tags: + type: EnumList + required: false +``` + +#### Type Mapping Reference + +| Old Type | New Type(s) | Notes | +|----------|-------------|-------| +| `string` | `Text`, `Email`, `URL`, `Phone`, `Address`, `Name` | Choose based on data | +| `number` | `Number`, `Decimal`, `Price`, `Percent` | Choose based on usage | +| `boolean` | `YesNo` | Accepts boolean or "Yes"/"No" strings | +| `date` | `Date`, `DateTime`, `Time`, `Duration` | Choose based on precision | +| `array` | `EnumList`, `RefList` | Choose based on usage | +| `object` | (various) | Depends on context | + +### Step 2: Update Enum Definitions + +```yaml +# Before (v1.x) +status: + type: string + enum: ["Active", "Inactive", "Pending"] + +# After (v2.0.0) +status: + type: Enum + required: true + allowedValues: ["Active", "Inactive", "Pending"] +``` + +### Step 3: Use CLI to Generate New Schema + +The easiest way to migrate is to use the CLI inspect command: + +```bash +# Generate new schema from existing AppSheet app +npx appsheet inspect \ + --app-id YOUR_APP_ID \ + --key YOUR_ACCESS_KEY \ + --output schema.yaml + +# The CLI will auto-detect field types including: +# - Email addresses +# - URLs +# - Phone numbers +# - Enum fields (with extracted allowedValues) +# - EnumList fields +# - Dates, DateTimes, Percentages, etc. +``` + +### Step 4: Review Auto-Generated Schema + +The CLI uses smart heuristics but may need manual adjustments: + +```yaml +# CLI might detect this as Text +website: + type: Text + +# But you should change it to URL for validation +website: + type: URL + required: false +``` + +### Step 5: Test Your Schema + +```bash +# Validate schema structure +npx appsheet validate --schema schema.yaml + +# Run your application tests +npm test +``` + +## Common Migration Patterns + +### Pattern 1: Simple String Fields + +```yaml +# Before +name: string + +# After - Choose appropriate type +name: + type: Text # Generic text +# or +email: + type: Email # Email with validation +# or +website: + type: URL # URL with validation +``` + +### Pattern 2: Enum Fields + +```yaml +# Before +priority: + type: string + enum: ["Low", "Medium", "High"] + +# After +priority: + type: Enum + required: true + allowedValues: ["Low", "Medium", "High"] +``` + +### Pattern 3: Multi-Select Fields + +```yaml +# Before +skills: + type: array + +# After +skills: + type: EnumList + required: false + allowedValues: ["JavaScript", "TypeScript", "Python"] +``` + +### Pattern 4: Numeric Fields + +```yaml +# Before +quantity: number +price: number +discount: number + +# After - Choose appropriate numeric type +quantity: + type: Number # Integer or decimal + required: true +price: + type: Price # Currency value + required: true +discount: + type: Percent # 0.00 to 1.00 + required: false +``` + +## Validation Changes + +Version 2.0.0 adds comprehensive validation: + +### Email Validation +```typescript +// Now validates email format +await table.add([{ email: 'invalid' }]); +// ❌ Error: must be a valid email address +``` + +### URL Validation +```typescript +// Now validates URL format +await table.add([{ website: 'not-a-url' }]); +// ❌ Error: must be a valid URL +``` + +### Enum Validation +```typescript +// Now validates against allowedValues +await table.add([{ status: 'Unknown' }]); +// ❌ Error: must be one of: Active, Inactive, Pending +``` + +### Percent Range Validation +```typescript +// Now validates range (0.00 to 1.00) +await table.add([{ discount: 1.5 }]); +// ❌ Error: must be between 0.00 and 1.00 +``` + +## Automated Migration Tool + +For large schemas, consider this migration script: + +```typescript +import * as fs from 'fs'; +import * as yaml from 'yaml'; + +const oldSchema = yaml.parse(fs.readFileSync('old-schema.yaml', 'utf-8')); + +// Type mapping +const typeMap = { + 'string': 'Text', + 'number': 'Number', + 'boolean': 'YesNo', + 'date': 'Date', + 'array': 'EnumList', + 'object': 'Text', +}; + +function migrateField(oldField: any) { + if (typeof oldField === 'string') { + // Shorthand format + return { + type: typeMap[oldField] || 'Text', + required: false, + }; + } + + // Full object format + const newField: any = { + type: typeMap[oldField.type] || 'Text', + required: oldField.required || false, + }; + + // Rename enum → allowedValues + if (oldField.enum) { + newField.type = 'Enum'; + newField.allowedValues = oldField.enum; + } + + if (oldField.description) { + newField.description = oldField.description; + } + + return newField; +} + +// Migrate all tables +for (const [connName, conn] of Object.entries(oldSchema.connections)) { + for (const [tableName, table] of Object.entries((conn as any).tables)) { + const newFields: any = {}; + for (const [fieldName, fieldDef] of Object.entries((table as any).fields)) { + newFields[fieldName] = migrateField(fieldDef); + } + (table as any).fields = newFields; + } +} + +fs.writeFileSync('migrated-schema.yaml', yaml.stringify(oldSchema)); +console.log('✓ Schema migrated to v2.0.0 format'); +console.log('⚠ Please review and adjust field types manually'); +``` + +## Troubleshooting + +### Error: "Type 'string' is not assignable to type 'AppSheetFieldType'" + +**Cause**: Using old generic type in schema + +**Solution**: Replace with AppSheet type: +```yaml +# ❌ Old +email: string + +# ✅ New +email: + type: Email +``` + +### Error: "Field 'enum' does not exist on type 'FieldDefinition'" + +**Cause**: Using old `enum` property + +**Solution**: Rename to `allowedValues`: +```yaml +# ❌ Old +status: + type: string + enum: ["Active"] + +# ✅ New +status: + type: Enum + allowedValues: ["Active"] +``` + +### Error: "Shorthand format no longer supported" + +**Cause**: Using shorthand string format + +**Solution**: Use full object format: +```yaml +# ❌ Old +name: string + +# ✅ New +name: + type: Text + required: false +``` + +## Getting Help + +- **Documentation**: See CLAUDE.md for complete type reference +- **CLI Help**: Run `npx appsheet inspect --help` +- **Examples**: Check `examples/` directory for v2.0.0 schemas +- **Issues**: Report problems at https://github.com/techdivision/appsheet/issues + +## Summary + +1. ✅ Replace generic types with AppSheet types +2. ✅ Convert all fields to full object format +3. ✅ Rename `enum` → `allowedValues` +4. ✅ Use CLI to auto-generate new schema +5. ✅ Review and test thoroughly + +**Estimated Migration Time**: 15-30 minutes per schema file (depending on size) diff --git a/src/cli/SchemaInspector.ts b/src/cli/SchemaInspector.ts index 3e3ef8d..bda9d8c 100644 --- a/src/cli/SchemaInspector.ts +++ b/src/cli/SchemaInspector.ts @@ -6,7 +6,13 @@ import * as readline from 'readline'; import { AppSheetClient } from '../client'; -import { TableInspectionResult, ConnectionDefinition, TableDefinition } from '../types'; +import { + TableInspectionResult, + ConnectionDefinition, + TableDefinition, + AppSheetFieldType, + FieldDefinition, +} from '../types'; /** * Inspects AppSheet tables and generates schema definitions. @@ -54,7 +60,7 @@ export class SchemaInspector { */ async inspectTable(tableName: string): Promise { try { - // Fetch some rows to infer field types + // Fetch rows to infer field types (limit to 100 for performance) const result = await this.client.find({ tableName, // No selector = get all rows @@ -69,17 +75,51 @@ export class SchemaInspector { }; } - // Analyze first row for field types - const sampleRow = result.rows[0]; - const fields: Record = {}; + // Analyze all rows (or sample if too many) + const sampleRows = result.rows.slice(0, 100); + const fields: Record = {}; - for (const [key, value] of Object.entries(sampleRow)) { - fields[key] = this.inferType(value); + // Get all field names from first row + const fieldNames = Object.keys(sampleRows[0]); + + for (const fieldName of fieldNames) { + // Collect all values for this field + const values = sampleRows.map((row) => row[fieldName]).filter((v) => v !== null && v !== undefined); + + if (values.length === 0) { + // No non-null values found + fields[fieldName] = { + type: 'Text', + required: false, + }; + continue; + } + + // Infer type from first non-null value + let inferredType = this.inferType(values[0]); + + // Check if Text field might actually be an Enum based on unique values + if (inferredType === 'Text' && this.looksLikeEnum(values)) { + inferredType = 'Enum'; + } + + // Build field definition + const fieldDef: FieldDefinition = { + type: inferredType, + required: false, // Cannot determine from data alone + }; + + // Extract allowedValues for Enum/EnumList + if (inferredType === 'Enum' || inferredType === 'EnumList') { + fieldDef.allowedValues = this.extractAllowedValues(values, inferredType); + } + + fields[fieldName] = fieldDef; } return { tableName, - keyField: this.guessKeyField(sampleRow), + keyField: this.guessKeyField(sampleRows[0]), fields, }; } catch (error: any) { @@ -88,29 +128,124 @@ export class SchemaInspector { } /** - * Infer field type from value + * Infer AppSheet field type from value with improved heuristics */ - private inferType(value: any): string { + private inferType(value: any): AppSheetFieldType { if (value === null || value === undefined) { - return 'string'; // Default + return 'Text'; // Default } const type = typeof value; - if (type === 'number') return 'number'; - if (type === 'boolean') return 'boolean'; - if (Array.isArray(value)) return 'array'; - if (type === 'object') return 'object'; + // Number types + if (type === 'number') { + // Check if it looks like a percent (0.00 to 1.00, excluding exact 0 and 1) + if (value > 0 && value < 1) { + return 'Percent'; + } + // Default to Number for all numeric values + return 'Number'; + } + + // Boolean or "Yes"/"No" strings + if (type === 'boolean' || value === 'Yes' || value === 'No') { + return 'YesNo'; + } + + // Arrays - could be EnumList + if (Array.isArray(value)) { + return 'EnumList'; + } - // Check if string looks like a date + // String pattern detection (order matters - most specific first) if (type === 'string') { - // ISO date format - if (/^\d{4}-\d{2}-\d{2}/.test(value)) { - return 'date'; + // DateTime pattern (ISO 8601 with time) - check before Date + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) { + return 'DateTime'; + } + + // Date pattern (YYYY-MM-DD) + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return 'Date'; + } + + // Email pattern + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + return 'Email'; + } + + // URL pattern (http/https) + if (/^https?:\/\//i.test(value)) { + return 'URL'; + } + + // Phone pattern - more restrictive to avoid false positives + // Must have at least 7 digits, may contain spaces, +, -, (, ) + if (/^[\d\s+\-()]{10,}$/.test(value) && /\d{7,}/.test(value.replace(/[\s+\-()]/g, ''))) { + return 'Phone'; + } + + // If string has very few unique values across dataset, it might be an Enum + // (This will be determined by caller based on analyzing multiple rows) + } + + // Default to Text for strings and unknown types + return 'Text'; + } + + /** + * Check if a text field looks like an Enum based on unique values + * + * Heuristic: If there are relatively few unique values compared to total values, + * it's likely an enum field (e.g., status, category, priority). + */ + private looksLikeEnum(values: any[]): boolean { + // Only consider string values + const stringValues = values.filter((v) => typeof v === 'string'); + if (stringValues.length === 0) { + return false; + } + + // Get unique values + const uniqueValues = new Set(stringValues); + + // Heuristics: + // 1. If less than 10 unique values total, likely an enum + // 2. If unique values are less than 20% of total values, likely an enum + // 3. Must have at least 2 samples + if (stringValues.length < 2) { + return false; + } + + if (uniqueValues.size <= 10) { + return true; + } + + const uniqueRatio = uniqueValues.size / stringValues.length; + return uniqueRatio < 0.2; + } + + /** + * Extract allowed values for Enum/EnumList fields + */ + private extractAllowedValues(values: any[], fieldType: AppSheetFieldType): string[] { + const uniqueValues = new Set(); + + for (const value of values) { + if (fieldType === 'EnumList' && Array.isArray(value)) { + // For EnumList, collect all values from all arrays + value.forEach((v) => { + if (typeof v === 'string') { + uniqueValues.add(v); + } + }); + } else if (fieldType === 'Enum' && typeof value === 'string') { + // For Enum, collect unique string values + uniqueValues.add(value); } } - return 'string'; + return Array.from(uniqueValues).sort(); } /** diff --git a/src/client/DynamicTable.ts b/src/client/DynamicTable.ts index 71b8783..681330e 100644 --- a/src/client/DynamicTable.ts +++ b/src/client/DynamicTable.ts @@ -5,7 +5,8 @@ */ import { AppSheetClient } from './AppSheetClient'; -import { TableDefinition, ValidationError } from '../types'; +import { TableDefinition } from '../types'; +import { AppSheetTypeValidator } from '../utils/validators'; /** * Table client with schema-based operations and runtime validation. @@ -280,22 +281,25 @@ export class DynamicTable> { } /** - * Validate rows based on schema + * Validate rows based on schema using AppSheetTypeValidator */ private validateRows(rows: Partial[], checkRequired = true): void { for (let i = 0; i < rows.length; i++) { const row = rows[i]; for (const [fieldName, fieldDef] of Object.entries(this.definition.fields)) { - const fieldType = typeof fieldDef === 'string' ? fieldDef : fieldDef.type; - const isRequired = typeof fieldDef === 'object' && fieldDef.required; + const fieldType = fieldDef.type; + const isRequired = fieldDef.required === true; const value = (row as any)[fieldName]; // Check required fields (only for add operations) - if (checkRequired && isRequired && (value === undefined || value === null)) { - throw new ValidationError( - `Row ${i}: Field "${fieldName}" is required in table "${this.definition.tableName}"`, - { row, fieldName } + if (checkRequired && isRequired) { + AppSheetTypeValidator.validateRequired( + fieldName, + this.definition.tableName, + value, + row, + i ); } @@ -304,108 +308,15 @@ export class DynamicTable> { continue; } - // Type validation - this.validateFieldType(i, fieldName, fieldType, value); + // Type validation using AppSheetTypeValidator + AppSheetTypeValidator.validate(fieldName, fieldType, value, i); - // Enum validation - if (typeof fieldDef === 'object' && fieldDef.enum) { - this.validateEnum(i, fieldName, fieldDef.enum, value); + // Enum/EnumList validation + if (fieldDef.allowedValues) { + AppSheetTypeValidator.validateEnum(fieldName, fieldType, fieldDef.allowedValues, value, i); } } } } - /** - * Validate field type - */ - private validateFieldType( - rowIndex: number, - fieldName: string, - expectedType: string, - value: any - ): void { - const actualType = Array.isArray(value) ? 'array' : typeof value; - - switch (expectedType) { - case 'number': - if (actualType !== 'number') { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be a number, got ${actualType}`, - { fieldName, expectedType, actualType, value } - ); - } - break; - - case 'boolean': - if (actualType !== 'boolean') { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be a boolean, got ${actualType}`, - { fieldName, expectedType, actualType, value } - ); - } - break; - - case 'array': - if (!Array.isArray(value)) { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be an array, got ${actualType}`, - { fieldName, expectedType, actualType, value } - ); - } - break; - - case 'object': - if (actualType !== 'object' || Array.isArray(value)) { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be an object, got ${actualType}`, - { fieldName, expectedType, actualType, value } - ); - } - break; - - case 'date': - // Accept string, Date object, or ISO date format - if (actualType === 'string') { - // Basic ISO date check - if (!/^\d{4}-\d{2}-\d{2}/.test(value)) { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be a valid date string (YYYY-MM-DD...)`, - { fieldName, value } - ); - } - } else if (!(value instanceof Date)) { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be a date string or Date object`, - { fieldName, value } - ); - } - break; - - case 'string': - if (actualType !== 'string') { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be a string, got ${actualType}`, - { fieldName, expectedType, actualType, value } - ); - } - break; - } - } - - /** - * Validate enum value - */ - private validateEnum( - rowIndex: number, - fieldName: string, - allowedValues: string[], - value: any - ): void { - if (!allowedValues.includes(value)) { - throw new ValidationError( - `Row ${rowIndex}: Field "${fieldName}" must be one of: ${allowedValues.join(', ')}. Got: ${value}`, - { fieldName, allowedValues, value } - ); - } - } } diff --git a/src/types/schema.ts b/src/types/schema.ts index 1862cbf..8c72c58 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -5,34 +5,128 @@ */ /** - * Supported field data types in schema definitions. + * AppSheet-specific field data types. + * + * Represents all column types supported by AppSheet API. + * * @category Types + * @see https://support.google.com/appsheet/answer/10106435 */ -export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'array' | 'object'; +export type AppSheetFieldType = + // Core types + | 'Text' + | 'Number' + | 'Date' + | 'DateTime' + | 'Time' + | 'Duration' + | 'YesNo' + // Specialized text types + | 'Name' + | 'Email' + | 'URL' + | 'Phone' + | 'Address' + // Specialized number types + | 'Decimal' + | 'Percent' + | 'Price' + // Selection types + | 'Enum' + | 'EnumList' + // Media types + | 'Image' + | 'File' + | 'Drawing' + | 'Signature' + // Tracking types + | 'ChangeCounter' + | 'ChangeTimestamp' + | 'ChangeLocation' + // Reference types + | 'Ref' + | 'RefList' + // Special types + | 'Color' + | 'Show'; /** - * Field definition with optional validation metadata. + * Field definition with AppSheet-specific type information and validation rules. * - * Defines a table field with type information and optional validation rules. + * Defines a table field with AppSheet type information and optional validation metadata. + * All fields must use the full object definition format with explicit type property. * * @category Types + * + * @example + * ```typescript + * const fieldDef: FieldDefinition = { + * type: 'Email', + * required: true, + * description: 'User email address' + * }; + * + * const enumFieldDef: FieldDefinition = { + * type: 'Enum', + * required: true, + * allowedValues: ['Active', 'Inactive', 'Pending'] + * }; + * ``` */ export interface FieldDefinition { - /** Field data type */ - type: FieldType; + /** AppSheet field type (required) */ + type: AppSheetFieldType; - /** Whether the field is required */ + /** Whether the field is required (default: false) */ required?: boolean; - /** Allowed values for enum fields */ - enum?: string[]; + /** Allowed values for Enum/EnumList fields */ + allowedValues?: string[]; + + /** Referenced table name for Ref/RefList fields */ + referencedTable?: string; /** Field description */ description?: string; + + /** Additional AppSheet-specific configuration */ + appSheetConfig?: { + /** Allow other values (for Enum) */ + allowOtherValues?: boolean; + + /** Format hint for display */ + format?: string; + + /** Default value */ + defaultValue?: any; + }; } /** - * Table definition in schema + * Table definition in schema. + * + * Defines a table structure with AppSheet-specific field types and validation rules. + * All fields must use the FieldDefinition object format (no shorthand strings). + * + * @category Types + * + * @example + * ```typescript + * const tableDef: TableDefinition = { + * tableName: 'extract_user', + * keyField: 'id', + * fields: { + * id: { type: 'Text', required: true }, + * email: { type: 'Email', required: true }, + * age: { type: 'Number', required: false }, + * status: { + * type: 'Enum', + * required: true, + * allowedValues: ['Active', 'Inactive'] + * } + * } + * }; + * ``` */ export interface TableDefinition { /** Actual AppSheet table name */ @@ -41,8 +135,8 @@ export interface TableDefinition { /** Name of the key/primary field */ keyField: string; - /** Field definitions (name -> type or full definition) */ - fields: Record; + /** Field definitions (name -> FieldDefinition object only) */ + fields: Record; } /** @@ -74,7 +168,11 @@ export interface SchemaConfig { } /** - * Result from table inspection + * Result from table inspection. + * + * Contains discovered table structure with inferred AppSheet field types. + * + * @category Types */ export interface TableInspectionResult { /** Table name */ @@ -83,8 +181,8 @@ export interface TableInspectionResult { /** Inferred key field */ keyField: string; - /** Discovered fields */ - fields: Record; + /** Discovered fields with AppSheet types */ + fields: Record; /** Optional warning message */ warning?: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index 98656b8..a9b83e7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './ConnectionManager'; export * from './SchemaLoader'; export * from './SchemaManager'; +export * from './validators'; diff --git a/src/utils/validators/AppSheetTypeValidator.ts b/src/utils/validators/AppSheetTypeValidator.ts new file mode 100644 index 0000000..43554fb --- /dev/null +++ b/src/utils/validators/AppSheetTypeValidator.ts @@ -0,0 +1,172 @@ +/** + * AppSheet-specific type validation + * @module utils + * @category Validation + */ + +import { AppSheetFieldType, ValidationError } from '../../types'; +import { BaseTypeValidator } from './BaseTypeValidator'; +import { FormatValidator } from './FormatValidator'; + +/** + * Validates AppSheet field types with format and constraint checking. + * + * Combines base type validation with AppSheet-specific format validation + * for all supported field types. + * + * @category Validation + */ +export class AppSheetTypeValidator { + /** + * Validate a field value against its AppSheet field type + */ + static validate( + fieldName: string, + fieldType: AppSheetFieldType, + value: any, + rowIndex: number + ): void { + switch (fieldType) { + // Core numeric types + case 'Number': + case 'Decimal': + case 'Price': + case 'ChangeCounter': + BaseTypeValidator.validateNumber(fieldName, fieldType, value, rowIndex); + break; + + // Percent: number with range validation + case 'Percent': + BaseTypeValidator.validateNumber(fieldName, fieldType, value, rowIndex); + FormatValidator.validatePercentRange(fieldName, value, rowIndex); + break; + + // Boolean type + case 'YesNo': + BaseTypeValidator.validateBoolean(fieldName, value, rowIndex); + break; + + // Array types + case 'EnumList': + case 'RefList': + BaseTypeValidator.validateArray(fieldName, fieldType, value, rowIndex); + break; + + // Date types + case 'Date': + if (BaseTypeValidator.validateDateValue(fieldName, value, rowIndex)) { + if (typeof value === 'string') { + FormatValidator.validateDateFormat(fieldName, value, rowIndex); + } + } + break; + + case 'DateTime': + case 'ChangeTimestamp': + if (BaseTypeValidator.validateDateValue(fieldName, value, rowIndex)) { + if (typeof value === 'string') { + FormatValidator.validateDateTimeFormat(fieldName, value, rowIndex); + } + } + break; + + // Time and Duration (string format) + case 'Time': + case 'Duration': + BaseTypeValidator.validateString(fieldName, fieldType, value, rowIndex); + break; + + // Email with format validation + case 'Email': + BaseTypeValidator.validateString(fieldName, fieldType, value, rowIndex); + FormatValidator.validateEmail(fieldName, value, rowIndex); + break; + + // URL with format validation + case 'URL': + BaseTypeValidator.validateString(fieldName, fieldType, value, rowIndex); + FormatValidator.validateURL(fieldName, value, rowIndex); + break; + + // Phone with format validation + case 'Phone': + BaseTypeValidator.validateString(fieldName, fieldType, value, rowIndex); + FormatValidator.validatePhone(fieldName, value, rowIndex); + break; + + // Text-based types (no additional validation) + case 'Text': + case 'Name': + case 'Address': + case 'Color': + case 'Enum': + case 'Ref': + case 'Image': + case 'File': + case 'Drawing': + case 'Signature': + case 'ChangeLocation': + case 'Show': + BaseTypeValidator.validateString(fieldName, fieldType, value, rowIndex); + break; + + default: + // Unknown type - skip validation + break; + } + } + + /** + * Validate enum value against allowed values + */ + static validateEnum( + fieldName: string, + fieldType: AppSheetFieldType, + allowedValues: string[], + value: any, + rowIndex: number + ): void { + if (fieldType === 'EnumList') { + // EnumList: validate array of values + if (!Array.isArray(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be an array for EnumList type`, + { fieldName, value } + ); + } + const invalidValues = value.filter((v) => !allowedValues.includes(v)); + if (invalidValues.length > 0) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" contains invalid values: ${invalidValues.join(', ')}. Allowed: ${allowedValues.join(', ')}`, + { fieldName, allowedValues, invalidValues } + ); + } + } else { + // Enum: validate single value + if (!allowedValues.includes(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be one of: ${allowedValues.join(', ')}. Got: ${value}`, + { fieldName, allowedValues, value } + ); + } + } + } + + /** + * Validate required field + */ + static validateRequired( + fieldName: string, + tableName: string, + value: any, + row: any, + rowIndex: number + ): void { + if (value === undefined || value === null) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" is required in table "${tableName}"`, + { row, fieldName } + ); + } + } +} diff --git a/src/utils/validators/BaseTypeValidator.ts b/src/utils/validators/BaseTypeValidator.ts new file mode 100644 index 0000000..dfb4d93 --- /dev/null +++ b/src/utils/validators/BaseTypeValidator.ts @@ -0,0 +1,99 @@ +/** + * Base type validation for JavaScript primitive types + * @module utils + * @category Validation + */ + +import { ValidationError } from '../../types'; + +/** + * Validates basic JavaScript types (string, number, boolean, array). + * + * Provides foundational type checking before AppSheet-specific validation. + * + * @category Validation + */ +export class BaseTypeValidator { + /** + * Validate that value is a string + */ + static validateString( + fieldName: string, + fieldType: string, + value: any, + rowIndex: number + ): void { + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== 'string') { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a string (${fieldType}), got ${actualType}`, + { fieldName, expectedType: fieldType, actualType, value } + ); + } + } + + /** + * Validate that value is a number + */ + static validateNumber( + fieldName: string, + fieldType: string, + value: any, + rowIndex: number + ): void { + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== 'number') { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a number (${fieldType}), got ${actualType}`, + { fieldName, expectedType: fieldType, actualType, value } + ); + } + } + + /** + * Validate that value is a boolean or "Yes"/"No" string + */ + static validateBoolean(fieldName: string, value: any, rowIndex: number): void { + const actualType = Array.isArray(value) ? 'array' : typeof value; + if (actualType !== 'boolean' && value !== 'Yes' && value !== 'No') { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a boolean or "Yes"/"No" string, got ${actualType}`, + { fieldName, expectedType: 'boolean', actualType, value } + ); + } + } + + /** + * Validate that value is an array + */ + static validateArray( + fieldName: string, + fieldType: string, + value: any, + rowIndex: number + ): void { + if (!Array.isArray(value)) { + const actualType = typeof value; + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be an array (${fieldType}), got ${actualType}`, + { fieldName, expectedType: fieldType, actualType, value } + ); + } + } + + /** + * Validate that value is a Date object or valid date string + */ + static validateDateValue(fieldName: string, value: any, rowIndex: number): boolean { + if (value instanceof Date) { + return true; + } + if (typeof value === 'string') { + return true; // Let format validator check the format + } + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a date string or Date object`, + { fieldName, value } + ); + } +} diff --git a/src/utils/validators/FormatValidator.ts b/src/utils/validators/FormatValidator.ts new file mode 100644 index 0000000..ea54e3d --- /dev/null +++ b/src/utils/validators/FormatValidator.ts @@ -0,0 +1,91 @@ +/** + * Format validation utilities for specialized field types + * @module utils + * @category Validation + */ + +import { ValidationError } from '../../types'; + +/** + * Validates format-specific constraints for AppSheet field types. + * + * Provides validation methods for Email, URL, Phone, and other format-specific types. + * + * @category Validation + */ +export class FormatValidator { + /** + * Validate email format (RFC 5322 basic check) + */ + static validateEmail(fieldName: string, value: string, rowIndex: number): void { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a valid email address, got: ${value}`, + { fieldName, value } + ); + } + } + + /** + * Validate URL format + */ + static validateURL(fieldName: string, value: string, rowIndex: number): void { + try { + new URL(value); + } catch { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a valid URL, got: ${value}`, + { fieldName, value } + ); + } + } + + /** + * Validate phone number format (flexible international format) + */ + static validatePhone(fieldName: string, value: string, rowIndex: number): void { + // Basic phone validation: digits, spaces, +, -, (, ) + if (!/^[\d\s+\-()]+$/.test(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a valid phone number, got: ${value}`, + { fieldName, value } + ); + } + } + + /** + * Validate date format (YYYY-MM-DD) + */ + static validateDateFormat(fieldName: string, value: string, rowIndex: number): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a valid date string (YYYY-MM-DD)`, + { fieldName, value } + ); + } + } + + /** + * Validate datetime format (ISO 8601) + */ + static validateDateTimeFormat(fieldName: string, value: string, rowIndex: number): void { + if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a valid datetime string (ISO 8601)`, + { fieldName, value } + ); + } + } + + /** + * Validate percentage range (0.00 to 1.00) + */ + static validatePercentRange(fieldName: string, value: number, rowIndex: number): void { + if (value < 0 || value > 1) { + throw new ValidationError( + `Row ${rowIndex}: Field "${fieldName}" must be a percentage between 0.00 and 1.00, got: ${value}`, + { fieldName, value } + ); + } + } +} diff --git a/src/utils/validators/index.ts b/src/utils/validators/index.ts new file mode 100644 index 0000000..a2a4aa8 --- /dev/null +++ b/src/utils/validators/index.ts @@ -0,0 +1,9 @@ +/** + * Validation utilities + * @module utils + * @category Validation + */ + +export { BaseTypeValidator } from './BaseTypeValidator'; +export { FormatValidator } from './FormatValidator'; +export { AppSheetTypeValidator } from './AppSheetTypeValidator'; diff --git a/tests/cli/SchemaInspector.test.ts b/tests/cli/SchemaInspector.test.ts new file mode 100644 index 0000000..dd9f351 --- /dev/null +++ b/tests/cli/SchemaInspector.test.ts @@ -0,0 +1,503 @@ +/** + * Tests for SchemaInspector type inference and enum detection + */ + +import { SchemaInspector } from '../../src/cli/SchemaInspector'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; + +// Mock AppSheetClient +jest.mock('../../src/client/AppSheetClient'); + +describe('SchemaInspector', () => { + let mockClient: jest.Mocked; + let inspector: SchemaInspector; + + beforeEach(() => { + mockClient = new AppSheetClient({ + appId: 'test', + applicationAccessKey: 'test', + }) as jest.Mocked; + + inspector = new SchemaInspector(mockClient); + }); + + describe('Type inference', () => { + it('should infer Email type from email addresses', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', email: 'user1@example.com' }, + { id: '2', email: 'user2@example.com' }, + { id: '3', email: 'test@domain.co.uk' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('users'); + + expect(result.fields.email).toEqual({ + type: 'Email', + required: false, + }); + }); + + it('should infer URL type from URLs', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', website: 'https://example.com' }, + { id: '2', website: 'http://localhost:3000' }, + { id: '3', website: 'https://sub.domain.com/path' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('links'); + + expect(result.fields.website).toEqual({ + type: 'URL', + required: false, + }); + }); + + it('should infer Phone type from phone numbers', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', phone: '+1 234 567 8900' }, + { id: '2', phone: '(123) 456-7890' }, + { id: '3', phone: '+49 123 456789' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('contacts'); + + expect(result.fields.phone).toEqual({ + type: 'Phone', + required: false, + }); + }); + + it('should infer Date type from date strings', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', date: '2025-11-20' }, + { id: '2', date: '2024-01-01' }, + { id: '3', date: '2025-12-31' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('events'); + + expect(result.fields.date).toEqual({ + type: 'Date', + required: false, + }); + }); + + it('should infer DateTime type from ISO datetime strings', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', timestamp: '2025-11-20T10:30:00Z' }, + { id: '2', timestamp: '2024-01-01T00:00:00+01:00' }, + { id: '3', timestamp: '2025-12-31T23:59:59Z' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('logs'); + + expect(result.fields.timestamp).toEqual({ + type: 'DateTime', + required: false, + }); + }); + + it('should infer Percent type from decimal values between 0 and 1', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', progress: 0.25 }, + { id: '2', progress: 0.5 }, + { id: '3', progress: 0.75 }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('tasks'); + + expect(result.fields.progress).toEqual({ + type: 'Percent', + required: false, + }); + }); + + it('should infer Number type for other numeric values', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', quantity: 10, price: 99.99 }, + { id: '2', quantity: 5, price: 199.99 }, + { id: '3', quantity: 0, price: 0 }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('products'); + + expect(result.fields.quantity.type).toBe('Number'); + expect(result.fields.price.type).toBe('Number'); + }); + + it('should infer YesNo type from boolean values', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', active: true }, + { id: '2', active: false }, + { id: '3', active: true }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('settings'); + + expect(result.fields.active).toEqual({ + type: 'YesNo', + required: false, + }); + }); + + it('should infer YesNo type from "Yes"/"No" strings', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', enabled: 'Yes' }, + { id: '2', enabled: 'No' }, + { id: '3', enabled: 'Yes' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('features'); + + expect(result.fields.enabled).toEqual({ + type: 'YesNo', + required: false, + }); + }); + + it('should infer EnumList type from arrays', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', tags: ['Frontend', 'JavaScript'] }, + { id: '2', tags: ['Backend', 'TypeScript'] }, + { id: '3', tags: ['Mobile', 'React'] }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('posts'); + + expect(result.fields.tags.type).toBe('EnumList'); + expect(result.fields.tags.allowedValues).toBeDefined(); + }); + }); + + describe('Enum detection heuristics', () => { + it('should detect enum with few unique values (≤10)', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', status: 'Active' }, + { id: '2', status: 'Inactive' }, + { id: '3', status: 'Pending' }, + { id: '4', status: 'Active' }, + { id: '5', status: 'Inactive' }, + { id: '6', status: 'Active' }, + { id: '7', status: 'Pending' }, + { id: '8', status: 'Active' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('users'); + + expect(result.fields.status.type).toBe('Enum'); + expect(result.fields.status.allowedValues).toEqual(['Active', 'Inactive', 'Pending']); + }); + + it('should detect enum with low unique ratio (<20%)', async () => { + // Create 100 rows with only 5 unique values (5% ratio) + const rows = []; + const statuses = ['Low', 'Medium', 'High', 'Critical', 'Urgent']; + for (let i = 0; i < 100; i++) { + rows.push({ id: `${i}`, priority: statuses[i % 5] }); + } + + mockClient.find.mockResolvedValue({ rows, warnings: [] }); + + const result = await inspector.inspectTable('issues'); + + expect(result.fields.priority.type).toBe('Enum'); + expect(result.fields.priority.allowedValues).toEqual([ + 'Critical', + 'High', + 'Low', + 'Medium', + 'Urgent', + ]); + }); + + it('should NOT detect enum with high unique ratio (>20%)', async () => { + // Create 100 rows with 50 unique values (50% ratio) - should not be detected as enum + const rows = []; + for (let i = 0; i < 100; i++) { + rows.push({ id: `${i}`, name: `User ${i % 50}` }); + } + + mockClient.find.mockResolvedValue({ rows, warnings: [] }); + + const result = await inspector.inspectTable('users'); + + expect(result.fields.name.type).toBe('Text'); + expect(result.fields.name.allowedValues).toBeUndefined(); + }); + + it('should extract sorted allowedValues for Enum', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', status: 'Pending' }, + { id: '2', status: 'Active' }, + { id: '3', status: 'Inactive' }, + { id: '4', status: 'Active' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('users'); + + expect(result.fields.status.allowedValues).toEqual(['Active', 'Inactive', 'Pending']); + }); + + it('should extract sorted allowedValues for EnumList', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', tags: ['JavaScript', 'TypeScript'] }, + { id: '2', tags: ['Python', 'JavaScript'] }, + { id: '3', tags: ['TypeScript', 'React'] }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('posts'); + + expect(result.fields.tags.allowedValues).toEqual([ + 'JavaScript', + 'Python', + 'React', + 'TypeScript', + ]); + }); + }); + + describe('Multi-row analysis', () => { + it('should analyze up to 100 rows for type inference', async () => { + // Create 150 rows - inspector should only analyze first 100 + const rows = []; + for (let i = 0; i < 150; i++) { + rows.push({ id: `${i}`, email: `user${i}@example.com` }); + } + + mockClient.find.mockResolvedValue({ rows, warnings: [] }); + + await inspector.inspectTable('users'); + + // Verify find was called without limit (gets all rows) + expect(mockClient.find).toHaveBeenCalledWith({ tableName: 'users' }); + }); + + it('should prefer first non-null value for type inference', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', email: null }, + { id: '2', email: undefined }, + { id: '3', email: 'user@example.com' }, + { id: '4', email: 'another@example.com' }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('users'); + + expect(result.fields.email.type).toBe('Email'); + }); + + it('should default to Text type when all values are null', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { id: '1', description: null }, + { id: '2', description: null }, + { id: '3', description: undefined }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('items'); + + expect(result.fields.description.type).toBe('Text'); + }); + }); + + describe('Key field detection', () => { + it('should detect "id" as key field', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ id: '1', name: 'Test' }], + warnings: [], + }); + + const result = await inspector.inspectTable('users'); + + expect(result.keyField).toBe('id'); + }); + + it('should detect "_RowNumber" as key field', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ _RowNumber: '1', name: 'Test' }], + warnings: [], + }); + + const result = await inspector.inspectTable('items'); + + expect(result.keyField).toBe('_RowNumber'); + }); + + it('should detect "Key" as key field', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ Key: '1', name: 'Test' }], + warnings: [], + }); + + const result = await inspector.inspectTable('records'); + + expect(result.keyField).toBe('Key'); + }); + + it('should fallback to first field if no common key found', async () => { + mockClient.find.mockResolvedValue({ + rows: [{ custom_pk: '1', name: 'Test' }], + warnings: [], + }); + + const result = await inspector.inspectTable('custom'); + + expect(result.keyField).toBe('custom_pk'); + }); + }); + + describe('Empty table handling', () => { + it('should handle empty table', async () => { + mockClient.find.mockResolvedValue({ rows: [], warnings: [] }); + + const result = await inspector.inspectTable('empty_table'); + + expect(result.fields).toEqual({}); + expect(result.warning).toBe('Table is empty, could not infer field types'); + expect(result.keyField).toBe('id'); // Default + }); + }); + + describe('Schema name conversion', () => { + it('should remove "extract_" prefix and add "s" suffix', () => { + expect(inspector.toSchemaName('extract_user')).toBe('users'); + expect(inspector.toSchemaName('extract_worklog')).toBe('worklogs'); + }); + + it('should remove underscores and add "s" suffix', () => { + expect(inspector.toSchemaName('work_log')).toBe('worklogs'); + expect(inspector.toSchemaName('user_profile')).toBe('userprofiles'); + }); + + it('should handle simple table names', () => { + expect(inspector.toSchemaName('user')).toBe('users'); + expect(inspector.toSchemaName('item')).toBe('items'); + }); + }); + + describe('Error handling', () => { + it('should throw error with table name when inspection fails', async () => { + mockClient.find.mockRejectedValue(new Error('API error')); + + await expect(inspector.inspectTable('users')).rejects.toThrow( + 'Failed to inspect table "users": API error' + ); + }); + }); + + describe('generateSchema', () => { + it('should generate schema for multiple tables', async () => { + // Mock responses for two tables + mockClient.find + .mockResolvedValueOnce({ + rows: [{ id: '1', email: 'user@example.com', name: 'User 1' }], + warnings: [], + }) + .mockResolvedValueOnce({ + rows: [{ id: '1', date: '2025-11-20', hours: 8, description: 'Work' }], + warnings: [], + }); + + // Suppress console.log during test + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const schema = await inspector.generateSchema('default', ['extract_user', 'extract_worklog']); + + expect(schema.appId).toBe('${APPSHEET_APP_ID}'); + expect(schema.applicationAccessKey).toBe('${APPSHEET_ACCESS_KEY}'); + expect(Object.keys(schema.tables)).toEqual(['users', 'worklogs']); + + expect(schema.tables.users.tableName).toBe('extract_user'); + expect(schema.tables.users.fields.email.type).toBe('Email'); + + expect(schema.tables.worklogs.tableName).toBe('extract_worklog'); + expect(schema.tables.worklogs.fields.date.type).toBe('Date'); + expect(schema.tables.worklogs.fields.hours.type).toBe('Number'); + + consoleSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + }); + + describe('Mixed type scenarios', () => { + it('should handle table with all field types', async () => { + mockClient.find.mockResolvedValue({ + rows: [ + { + id: '1', + name: 'Project Alpha', + email: 'contact@alpha.com', + website: 'https://alpha.com', + phone: '+1234567890', + status: 'Active', + tags: ['Frontend', 'Backend'], + progress: 0.35, + startDate: '2025-01-01', + lastUpdate: '2025-11-20T10:30:00Z', + budget: 50000, + active: true, + }, + ], + warnings: [], + }); + + const result = await inspector.inspectTable('projects'); + + expect(result.fields.name.type).toBe('Text'); + expect(result.fields.email.type).toBe('Email'); + expect(result.fields.website.type).toBe('URL'); + expect(result.fields.phone.type).toBe('Phone'); + expect(result.fields.status.type).toBe('Text'); // Only 1 value, can't detect enum + expect(result.fields.tags.type).toBe('EnumList'); + expect(result.fields.progress.type).toBe('Percent'); + expect(result.fields.startDate.type).toBe('Date'); + expect(result.fields.lastUpdate.type).toBe('DateTime'); + expect(result.fields.budget.type).toBe('Number'); + expect(result.fields.active.type).toBe('YesNo'); + }); + }); +}); diff --git a/tests/client/DynamicTable.test.ts b/tests/client/DynamicTable.test.ts new file mode 100644 index 0000000..085e79f --- /dev/null +++ b/tests/client/DynamicTable.test.ts @@ -0,0 +1,939 @@ +/** + * Tests for DynamicTable with AppSheet field types + */ + +import { DynamicTable } from '../../src/client/DynamicTable'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { TableDefinition, ValidationError } from '../../src/types'; + +// Mock AppSheetClient +jest.mock('../../src/client/AppSheetClient'); + +describe('DynamicTable - AppSheet Field Types', () => { + let mockClient: jest.Mocked; + let tableDef: TableDefinition; + + beforeEach(() => { + mockClient = new AppSheetClient({ + appId: 'test', + applicationAccessKey: 'test', + }) as jest.Mocked; + + // Mock successful responses + mockClient.add.mockResolvedValue({ rows: [], warnings: [] }); + mockClient.update.mockResolvedValue({ rows: [], warnings: [] }); + }); + + describe('Email field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }; + }); + + it('should accept valid email addresses', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', email: 'user@example.com' }, + { id: '2', email: 'test.user+tag@domain.co.uk' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject invalid email addresses', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', email: 'invalid-email' }])).rejects.toThrow( + ValidationError + ); + + await expect(table.add([{ id: '1', email: '@example.com' }])).rejects.toThrow( + ValidationError + ); + + await expect(table.add([{ id: '1', email: 'user@' }])).rejects.toThrow(ValidationError); + }); + + it('should include field name in error message', async () => { + const table = new DynamicTable(mockClient, tableDef); + + try { + await table.add([{ id: '1', email: 'invalid' }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('email'); + expect(error.message).toContain('valid email address'); + } + }); + }); + + describe('URL field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'links', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + website: { type: 'URL', required: false }, + }, + }; + }); + + it('should accept valid URLs', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', website: 'https://example.com' }, + { id: '2', website: 'http://localhost:3000/path' }, + { id: '3', website: 'https://sub.domain.com/path?query=1' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject invalid URLs', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', website: 'not-a-url' }])).rejects.toThrow( + ValidationError + ); + + await expect(table.add([{ id: '1', website: 'example.com' }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('Phone field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'contacts', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + phone: { type: 'Phone', required: false }, + }, + }; + }); + + it('should accept valid phone numbers', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', phone: '+1 234 567 8900' }, + { id: '2', phone: '(123) 456-7890' }, + { id: '3', phone: '+49 123 456789' }, + { id: '4', phone: '1234567890' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject invalid phone numbers', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', phone: 'abc123' }])).rejects.toThrow(ValidationError); + + await expect(table.add([{ id: '1', phone: 'invalid phone!' }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('Enum field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + status: { + type: 'Enum', + required: true, + allowedValues: ['Active', 'Inactive', 'Pending'], + }, + }, + }; + }); + + it('should accept valid enum values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', status: 'Active' }, + { id: '2', status: 'Inactive' }, + { id: '3', status: 'Pending' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject invalid enum values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', status: 'Unknown' }])).rejects.toThrow(ValidationError); + + await expect(table.add([{ id: '1', status: 'active' }])).rejects.toThrow( + ValidationError + ); // Case sensitive + }); + + it('should include allowed values in error message', async () => { + const table = new DynamicTable(mockClient, tableDef); + + try { + await table.add([{ id: '1', status: 'Invalid' }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('Active, Inactive, Pending'); + expect(error.message).toContain('Invalid'); + } + }); + }); + + describe('EnumList field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'posts', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + tags: { + type: 'EnumList', + required: false, + allowedValues: ['JavaScript', 'TypeScript', 'Node.js', 'React'], + }, + }, + }; + }); + + it('should accept valid enum list values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', tags: ['JavaScript', 'TypeScript'] }, + { id: '2', tags: ['Node.js'] }, + { id: '3', tags: ['React', 'TypeScript', 'JavaScript'] }, + ]) + ).resolves.not.toThrow(); + }); + + it('should accept empty arrays', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', tags: [] }])).resolves.not.toThrow(); + }); + + it('should reject arrays with invalid values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', tags: ['JavaScript', 'Python'] }])).rejects.toThrow( + ValidationError + ); + }); + + it('should reject non-array values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', tags: 'JavaScript' as any }])).rejects.toThrow( + ValidationError + ); + }); + + it('should include invalid values in error message', async () => { + const table = new DynamicTable(mockClient, tableDef); + + try { + await table.add([{ id: '1', tags: ['JavaScript', 'Python', 'Ruby'] }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('Python'); + expect(error.message).toContain('Ruby'); + expect(error.message).toContain('invalid values'); + } + }); + }); + + describe('Percent field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'discounts', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + rate: { type: 'Percent', required: true }, + }, + }; + }); + + it('should accept valid percentage values (0.00 to 1.00)', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', rate: 0.0 }, + { id: '2', rate: 0.5 }, + { id: '3', rate: 1.0 }, + { id: '4', rate: 0.25 }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject values outside 0.00-1.00 range', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', rate: -0.1 }])).rejects.toThrow(ValidationError); + + await expect(table.add([{ id: '1', rate: 1.5 }])).rejects.toThrow(ValidationError); + + await expect(table.add([{ id: '1', rate: 100 }])).rejects.toThrow(ValidationError); + }); + + it('should reject non-numeric values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', rate: '0.5' as any }])).rejects.toThrow(ValidationError); + }); + }); + + describe('Date field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'events', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + eventDate: { type: 'Date', required: true }, + }, + }; + }); + + it('should accept valid date strings (YYYY-MM-DD)', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', eventDate: '2025-11-20' }, + { id: '2', eventDate: '2024-01-01' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should accept Date objects', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', eventDate: new Date('2025-11-20') }])).resolves.not.toThrow(); + }); + + it('should reject invalid date formats', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', eventDate: '11/20/2025' }])).rejects.toThrow( + ValidationError + ); + + await expect(table.add([{ id: '1', eventDate: '2025-11-20T10:00:00Z' }])).rejects.toThrow( + ValidationError + ); // Should use DateTime type + }); + }); + + describe('DateTime field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'logs', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + timestamp: { type: 'DateTime', required: true }, + }, + }; + }); + + it('should accept valid datetime strings (ISO 8601)', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', timestamp: '2025-11-20T10:30:00Z' }, + { id: '2', timestamp: '2024-01-01T00:00:00+01:00' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should accept Date objects', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([{ id: '1', timestamp: new Date('2025-11-20T10:30:00Z') }]) + ).resolves.not.toThrow(); + }); + + it('should reject date-only strings', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', timestamp: '2025-11-20' }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('YesNo field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'settings', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + enabled: { type: 'YesNo', required: true }, + }, + }; + }); + + it('should accept boolean values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', enabled: true }, + { id: '2', enabled: false }, + ]) + ).resolves.not.toThrow(); + }); + + it('should accept "Yes"/"No" strings', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', enabled: 'Yes' }, + { id: '2', enabled: 'No' }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject other string values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', enabled: 'true' as any }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('Number types validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'products', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + quantity: { type: 'Number', required: true }, + price: { type: 'Price', required: true }, + discount: { type: 'Decimal', required: false }, + }, + }; + }); + + it('should accept numeric values for Number, Price, Decimal', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect( + table.add([ + { id: '1', quantity: 10, price: 99.99, discount: 0.15 }, + { id: '2', quantity: 0, price: 0.0, discount: 0 }, + ]) + ).resolves.not.toThrow(); + }); + + it('should reject non-numeric values', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', quantity: '10' as any, price: 99.99 }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('Required field validation', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + name: { type: 'Text', required: false }, + }, + }; + }); + + it('should reject missing required fields on add', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1' } as any])).rejects.toThrow(ValidationError); + }); + + it('should accept missing optional fields', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.add([{ id: '1', email: 'user@example.com' }])).resolves.not.toThrow(); + }); + + it('should not check required fields on update', async () => { + const table = new DynamicTable(mockClient, tableDef); + + // Only updating name, not providing email (required field) + await expect(table.update([{ id: '1', name: 'John' }])).resolves.not.toThrow(); + }); + }); + + describe('Update operations', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + status: { + type: 'Enum', + required: true, + allowedValues: ['Active', 'Inactive'], + }, + }, + }; + }); + + it('should validate provided fields on update', async () => { + const table = new DynamicTable(mockClient, tableDef); + + // Valid email update + await expect( + table.update([{ id: '1', email: 'newemail@example.com' }]) + ).resolves.not.toThrow(); + + // Invalid email update + await expect(table.update([{ id: '1', email: 'invalid' }])).rejects.toThrow(ValidationError); + }); + + it('should validate enum values on update', async () => { + const table = new DynamicTable(mockClient, tableDef); + + await expect(table.update([{ id: '1', status: 'Active' }])).resolves.not.toThrow(); + + await expect(table.update([{ id: '1', status: 'Unknown' }])).rejects.toThrow( + ValidationError + ); + }); + }); + + describe('Query methods', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + name: { type: 'Text', required: false }, + }, + }; + }); + + it('should find all rows', async () => { + const mockRows = [ + { id: '1', email: 'user1@example.com', name: 'User 1' }, + { id: '2', email: 'user2@example.com', name: 'User 2' }, + ]; + mockClient.find.mockResolvedValue({ rows: mockRows, warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.findAll(); + + expect(result).toEqual(mockRows); + expect(mockClient.find).toHaveBeenCalledWith({ tableName: 'users' }); + }); + + it('should find one row with selector', async () => { + const mockRow = { id: '1', email: 'user1@example.com', name: 'User 1' }; + mockClient.find.mockResolvedValue({ rows: [mockRow], warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.findOne('[Email] = "user1@example.com"'); + + expect(result).toEqual(mockRow); + expect(mockClient.find).toHaveBeenCalledWith({ + tableName: 'users', + selector: '[Email] = "user1@example.com"', + }); + }); + + it('should return null when findOne finds no rows', async () => { + mockClient.find.mockResolvedValue({ rows: [], warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.findOne('[Email] = "nonexistent@example.com"'); + + expect(result).toBeNull(); + }); + + it('should find rows with optional selector', async () => { + const mockRows = [{ id: '1', email: 'user1@example.com', name: 'User 1' }]; + mockClient.find.mockResolvedValue({ rows: mockRows, warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.find('[Status] = "Active"'); + + expect(result).toEqual(mockRows); + expect(mockClient.find).toHaveBeenCalledWith({ + tableName: 'users', + selector: '[Status] = "Active"', + }); + }); + + it('should find all rows when no selector provided', async () => { + const mockRows = [ + { id: '1', email: 'user1@example.com', name: 'User 1' }, + { id: '2', email: 'user2@example.com', name: 'User 2' }, + ]; + mockClient.find.mockResolvedValue({ rows: mockRows, warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.find(); + + expect(result).toEqual(mockRows); + expect(mockClient.find).toHaveBeenCalledWith({ tableName: 'users', selector: undefined }); + }); + }); + + describe('Delete operations', () => { + beforeEach(() => { + tableDef = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }; + }); + + it('should delete rows by key', async () => { + mockClient.delete.mockResolvedValue({ success: true, deletedCount: 2, warnings: [] }); + + const table = new DynamicTable(mockClient, tableDef); + const result = await table.delete([{ id: '1' }, { id: '2' }]); + + expect(result).toBe(true); + expect(mockClient.delete).toHaveBeenCalledWith({ + tableName: 'users', + rows: [{ id: '1' }, { id: '2' }], + }); + }); + }); + + describe('Table metadata methods', () => { + beforeEach(() => { + tableDef = { + tableName: 'extract_worklog', + keyField: 'worklog_id', + fields: { + worklog_id: { type: 'Text', required: true }, + date: { type: 'Date', required: true }, + hours: { type: 'Number', required: true }, + }, + }; + }); + + it('should return table definition', () => { + const table = new DynamicTable(mockClient, tableDef); + const def = table.getDefinition(); + + expect(def).toEqual(tableDef); + expect(def.tableName).toBe('extract_worklog'); + expect(def.keyField).toBe('worklog_id'); + expect(Object.keys(def.fields)).toEqual(['worklog_id', 'date', 'hours']); + }); + + it('should return table name', () => { + const table = new DynamicTable(mockClient, tableDef); + expect(table.getTableName()).toBe('extract_worklog'); + }); + + it('should return key field', () => { + const table = new DynamicTable(mockClient, tableDef); + expect(table.getKeyField()).toBe('worklog_id'); + }); + }); + + describe('Integration tests - Realistic workflows', () => { + it('should handle complete CRUD workflow with mixed field types', async () => { + const tableDef: TableDefinition = { + tableName: 'projects', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + name: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + website: { type: 'URL', required: false }, + status: { + type: 'Enum', + required: true, + allowedValues: ['Planning', 'Active', 'Completed', 'On Hold'], + }, + tags: { + type: 'EnumList', + required: false, + allowedValues: ['Frontend', 'Backend', 'Mobile', 'DevOps'], + }, + progress: { type: 'Percent', required: false }, + startDate: { type: 'Date', required: true }, + budget: { type: 'Price', required: false }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + // Create + mockClient.add.mockResolvedValue({ + rows: [ + { + id: '1', + name: 'Project Alpha', + email: 'contact@alpha.com', + website: 'https://alpha.com', + status: 'Active', + tags: ['Frontend', 'Backend'], + progress: 0.35, + startDate: '2025-01-01', + budget: 50000, + }, + ], + warnings: [], + }); + + const created = await table.add([ + { + id: '1', + name: 'Project Alpha', + email: 'contact@alpha.com', + website: 'https://alpha.com', + status: 'Active', + tags: ['Frontend', 'Backend'], + progress: 0.35, + startDate: '2025-01-01', + budget: 50000, + }, + ]); + + expect(created).toHaveLength(1); + expect(created[0].id).toBe('1'); + + // Read + mockClient.find.mockResolvedValue({ rows: created, warnings: [] }); + const found = await table.find('[Status] = "Active"'); + expect(found).toEqual(created); + + // Update + mockClient.update.mockResolvedValue({ + rows: [{ ...created[0], progress: 0.75, status: 'Completed' }], + warnings: [], + }); + + const updated = await table.update([{ id: '1', progress: 0.75, status: 'Completed' }]); + expect(updated[0].progress).toBe(0.75); + expect(updated[0].status).toBe('Completed'); + + // Delete + mockClient.delete.mockResolvedValue({ success: true, deletedCount: 1, warnings: [] }); + const deleted = await table.delete([{ id: '1' }]); + expect(deleted).toBe(true); + }); + + it('should handle validation errors in multi-step workflow', async () => { + const tableDef: TableDefinition = { + tableName: 'users', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + status: { + type: 'Enum', + required: true, + allowedValues: ['Active', 'Inactive', 'Suspended'], + }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + // Invalid email should fail + await expect( + table.add([{ id: '1', email: 'invalid-email', status: 'Active' }]) + ).rejects.toThrow(ValidationError); + + // Invalid enum should fail + await expect( + table.add([{ id: '1', email: 'user@example.com', status: 'Unknown' as any }]) + ).rejects.toThrow(ValidationError); + + // Valid data should succeed + mockClient.add.mockResolvedValue({ + rows: [{ id: '1', email: 'user@example.com', status: 'Active' }], + warnings: [], + }); + + await expect( + table.add([{ id: '1', email: 'user@example.com', status: 'Active' }]) + ).resolves.not.toThrow(); + }); + + it('should handle batch operations with partial updates', async () => { + const tableDef: TableDefinition = { + tableName: 'tasks', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + title: { type: 'Text', required: true }, + priority: { + type: 'Enum', + required: true, + allowedValues: ['Low', 'Medium', 'High', 'Critical'], + }, + progress: { type: 'Percent', required: false }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + // Batch update with different fields + mockClient.update.mockResolvedValue({ + rows: [ + { id: '1', title: 'Task 1', priority: 'High', progress: 0.8 }, + { id: '2', title: 'Task 2', priority: 'Low', progress: 0.1 }, + { id: '3', title: 'Task 3', priority: 'Critical', progress: 0.5 }, + ], + warnings: [], + }); + + const updated = await table.update([ + { id: '1', progress: 0.8 }, // Only update progress + { id: '2', priority: 'Low' }, // Only update priority + { id: '3', progress: 0.5, priority: 'Critical' }, // Update both + ]); + + expect(updated).toHaveLength(3); + expect(updated[0].progress).toBe(0.8); + expect(updated[1].priority).toBe('Low'); + expect(updated[2].progress).toBe(0.5); + }); + }); + + describe('Edge cases and error messages', () => { + it('should provide clear error messages for multiple validation failures', async () => { + const tableDef: TableDefinition = { + tableName: 'contacts', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + phone: { type: 'Phone', required: true }, + website: { type: 'URL', required: false }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + // Invalid email + try { + await table.add([{ id: '1', email: 'bad-email', phone: '+1234567890', website: 'https://example.com' }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('email'); + expect(error.message).toContain('valid email'); + } + + // Invalid phone + try { + await table.add([{ id: '1', email: 'user@example.com', phone: 'abc', website: 'https://example.com' }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('phone'); + } + + // Invalid URL + try { + await table.add([{ id: '1', email: 'user@example.com', phone: '+1234567890', website: 'not-a-url' }]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('website'); + expect(error.message).toContain('valid URL'); + } + }); + + it('should handle null and undefined values correctly', async () => { + const tableDef: TableDefinition = { + tableName: 'optional_fields', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + optionalEmail: { type: 'Email', required: false }, + optionalNumber: { type: 'Number', required: false }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + mockClient.add.mockResolvedValue({ + rows: [{ id: '1', optionalEmail: null, optionalNumber: undefined }], + warnings: [], + }); + + // Null/undefined for optional fields should be allowed + await expect( + table.add([{ id: '1', optionalEmail: null as any, optionalNumber: undefined }]) + ).resolves.not.toThrow(); + }); + + it('should validate row index in error messages', async () => { + const tableDef: TableDefinition = { + tableName: 'batch', + keyField: 'id', + fields: { + id: { type: 'Text', required: true }, + email: { type: 'Email', required: true }, + }, + }; + + const table = new DynamicTable(mockClient, tableDef); + + // Second row has invalid email + try { + await table.add([ + { id: '1', email: 'valid@example.com' }, + { id: '2', email: 'invalid-email' }, + { id: '3', email: 'another@example.com' }, + ]); + fail('Should have thrown ValidationError'); + } catch (error: any) { + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('Row 1'); // 0-indexed, shown as "Row 1" + } + }); + }); +});