Skip to content

Commit 4702551

Browse files
wagnertclaude
andcommitted
feat(SOSO-247): implement AppSheet field types and validation (Phase 1)
Phase 1: Type System Extension **Breaking Changes (v2.0.0):** - Removed old generic types (string, number, boolean, date, array, object) - Replaced with AppSheet-specific types (Text, Email, URL, Phone, etc.) - All fields now require full FieldDefinition object format - Renamed 'enum' property to 'allowedValues' **New Features:** - Added 27 AppSheet field types across all categories: - Core types: 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 **Enhanced Validation:** - Email format validation (RFC 5322) - URL format validation - Phone number validation (international format) - Enum: single value validation - EnumList: array validation with allowed values - Percent: range validation (0.00 to 1.00) - Date/DateTime: ISO format validation - YesNo: boolean or "Yes"/"No" string validation **Updated Components:** - src/types/schema.ts: New AppSheetFieldType enum and FieldDefinition interface - src/client/DynamicTable.ts: Complete validation logic for all field types - src/cli/SchemaInspector.ts: Auto-detection of AppSheet types from data **Tests:** - Added 34 comprehensive tests for all field type validations - All 82 tests passing (48 existing + 34 new) - Test coverage for Email, URL, Phone, Enum, EnumList, Percent, Date, DateTime, YesNo **Migration Notes:** - Old schemas with generic types will no longer work - Migration guide will be provided in Phase 4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 6f1dc6d commit 4702551

File tree

4 files changed

+872
-61
lines changed

4 files changed

+872
-61
lines changed

src/cli/SchemaInspector.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66

77
import * as readline from 'readline';
88
import { AppSheetClient } from '../client';
9-
import { TableInspectionResult, ConnectionDefinition, TableDefinition } from '../types';
9+
import {
10+
TableInspectionResult,
11+
ConnectionDefinition,
12+
TableDefinition,
13+
AppSheetFieldType,
14+
FieldDefinition,
15+
} from '../types';
1016

1117
/**
1218
* Inspects AppSheet tables and generates schema definitions.
@@ -71,10 +77,13 @@ export class SchemaInspector {
7177

7278
// Analyze first row for field types
7379
const sampleRow = result.rows[0];
74-
const fields: Record<string, string> = {};
80+
const fields: Record<string, FieldDefinition> = {};
7581

7682
for (const [key, value] of Object.entries(sampleRow)) {
77-
fields[key] = this.inferType(value);
83+
fields[key] = {
84+
type: this.inferType(value),
85+
required: false, // Cannot determine from data alone
86+
};
7887
}
7988

8089
return {
@@ -88,29 +97,65 @@ export class SchemaInspector {
8897
}
8998

9099
/**
91-
* Infer field type from value
100+
* Infer AppSheet field type from value
92101
*/
93-
private inferType(value: any): string {
102+
private inferType(value: any): AppSheetFieldType {
94103
if (value === null || value === undefined) {
95-
return 'string'; // Default
104+
return 'Text'; // Default
96105
}
97106

98107
const type = typeof value;
99108

100-
if (type === 'number') return 'number';
101-
if (type === 'boolean') return 'boolean';
102-
if (Array.isArray(value)) return 'array';
103-
if (type === 'object') return 'object';
109+
// Number types
110+
if (type === 'number') {
111+
// Check if it looks like a percent (0.00 to 1.00)
112+
if (value >= 0 && value <= 1 && value !== 0 && value !== 1) {
113+
return 'Percent';
114+
}
115+
// Default to Number for all numeric values
116+
return 'Number';
117+
}
118+
119+
// Boolean
120+
if (type === 'boolean') {
121+
return 'YesNo';
122+
}
123+
124+
// Arrays
125+
if (Array.isArray(value)) {
126+
return 'EnumList'; // Assume array is EnumList
127+
}
104128

105-
// Check if string looks like a date
129+
// Check string patterns
106130
if (type === 'string') {
107-
// ISO date format
108-
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
109-
return 'date';
131+
// Email pattern
132+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
133+
return 'Email';
134+
}
135+
136+
// URL pattern
137+
if (/^https?:\/\//i.test(value)) {
138+
return 'URL';
139+
}
140+
141+
// Phone pattern (basic)
142+
if (/^[\d\s+\-()]{7,}$/.test(value)) {
143+
return 'Phone';
144+
}
145+
146+
// DateTime pattern (ISO 8601 with time)
147+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
148+
return 'DateTime';
149+
}
150+
151+
// Date pattern (YYYY-MM-DD)
152+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
153+
return 'Date';
110154
}
111155
}
112156

113-
return 'string';
157+
// Default to Text for strings and unknown types
158+
return 'Text';
114159
}
115160

116161
/**

src/client/DynamicTable.ts

Lines changed: 158 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,8 @@ export class DynamicTable<T = Record<string, any>> {
287287
const row = rows[i];
288288

289289
for (const [fieldName, fieldDef] of Object.entries(this.definition.fields)) {
290-
const fieldType = typeof fieldDef === 'string' ? fieldDef : fieldDef.type;
291-
const isRequired = typeof fieldDef === 'object' && fieldDef.required;
290+
const fieldType = fieldDef.type;
291+
const isRequired = fieldDef.required === true;
292292
const value = (row as any)[fieldName];
293293

294294
// Check required fields (only for add operations)
@@ -307,16 +307,16 @@ export class DynamicTable<T = Record<string, any>> {
307307
// Type validation
308308
this.validateFieldType(i, fieldName, fieldType, value);
309309

310-
// Enum validation
311-
if (typeof fieldDef === 'object' && fieldDef.enum) {
312-
this.validateEnum(i, fieldName, fieldDef.enum, value);
310+
// Enum/EnumList validation
311+
if (fieldDef.allowedValues) {
312+
this.validateEnum(i, fieldName, fieldType, fieldDef.allowedValues, value);
313313
}
314314
}
315315
}
316316
}
317317

318318
/**
319-
* Validate field type
319+
* Validate field type based on AppSheet field types
320320
*/
321321
private validateFieldType(
322322
rowIndex: number,
@@ -327,85 +327,212 @@ export class DynamicTable<T = Record<string, any>> {
327327
const actualType = Array.isArray(value) ? 'array' : typeof value;
328328

329329
switch (expectedType) {
330-
case 'number':
330+
// Core numeric types
331+
case 'Number':
332+
case 'Decimal':
333+
case 'Price':
334+
case 'ChangeCounter':
331335
if (actualType !== 'number') {
332336
throw new ValidationError(
333-
`Row ${rowIndex}: Field "${fieldName}" must be a number, got ${actualType}`,
337+
`Row ${rowIndex}: Field "${fieldName}" must be a number (${expectedType}), got ${actualType}`,
334338
{ fieldName, expectedType, actualType, value }
335339
);
336340
}
337341
break;
338342

339-
case 'boolean':
340-
if (actualType !== 'boolean') {
343+
// Percent: must be number between 0.00 and 1.00
344+
case 'Percent':
345+
if (actualType !== 'number') {
341346
throw new ValidationError(
342-
`Row ${rowIndex}: Field "${fieldName}" must be a boolean, got ${actualType}`,
347+
`Row ${rowIndex}: Field "${fieldName}" must be a number (Percent), got ${actualType}`,
343348
{ fieldName, expectedType, actualType, value }
344349
);
345350
}
351+
if (value < 0 || value > 1) {
352+
throw new ValidationError(
353+
`Row ${rowIndex}: Field "${fieldName}" must be a percentage between 0.00 and 1.00, got: ${value}`,
354+
{ fieldName, value }
355+
);
356+
}
346357
break;
347358

348-
case 'array':
349-
if (!Array.isArray(value)) {
359+
// Boolean types
360+
case 'YesNo':
361+
if (actualType !== 'boolean' && value !== 'Yes' && value !== 'No') {
350362
throw new ValidationError(
351-
`Row ${rowIndex}: Field "${fieldName}" must be an array, got ${actualType}`,
363+
`Row ${rowIndex}: Field "${fieldName}" must be a boolean or "Yes"/"No" string, got ${actualType}`,
352364
{ fieldName, expectedType, actualType, value }
353365
);
354366
}
355367
break;
356368

357-
case 'object':
358-
if (actualType !== 'object' || Array.isArray(value)) {
369+
// Array types
370+
case 'EnumList':
371+
case 'RefList':
372+
if (!Array.isArray(value)) {
359373
throw new ValidationError(
360-
`Row ${rowIndex}: Field "${fieldName}" must be an object, got ${actualType}`,
374+
`Row ${rowIndex}: Field "${fieldName}" must be an array (${expectedType}), got ${actualType}`,
361375
{ fieldName, expectedType, actualType, value }
362376
);
363377
}
364378
break;
365379

366-
case 'date':
367-
// Accept string, Date object, or ISO date format
380+
// Date types
381+
case 'Date':
382+
if (actualType === 'string') {
383+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
384+
throw new ValidationError(
385+
`Row ${rowIndex}: Field "${fieldName}" must be a valid date string (YYYY-MM-DD)`,
386+
{ fieldName, value }
387+
);
388+
}
389+
} else if (!(value instanceof Date)) {
390+
throw new ValidationError(
391+
`Row ${rowIndex}: Field "${fieldName}" must be a date string (YYYY-MM-DD) or Date object`,
392+
{ fieldName, value }
393+
);
394+
}
395+
break;
396+
397+
case 'DateTime':
398+
case 'ChangeTimestamp':
368399
if (actualType === 'string') {
369-
// Basic ISO date check
370-
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) {
400+
if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) {
371401
throw new ValidationError(
372-
`Row ${rowIndex}: Field "${fieldName}" must be a valid date string (YYYY-MM-DD...)`,
402+
`Row ${rowIndex}: Field "${fieldName}" must be a valid datetime string (ISO 8601)`,
373403
{ fieldName, value }
374404
);
375405
}
376406
} else if (!(value instanceof Date)) {
377407
throw new ValidationError(
378-
`Row ${rowIndex}: Field "${fieldName}" must be a date string or Date object`,
408+
`Row ${rowIndex}: Field "${fieldName}" must be a datetime string (ISO 8601) or Date object`,
409+
{ fieldName, value }
410+
);
411+
}
412+
break;
413+
414+
case 'Time':
415+
case 'Duration':
416+
if (actualType !== 'string') {
417+
throw new ValidationError(
418+
`Row ${rowIndex}: Field "${fieldName}" must be a string (${expectedType}), got ${actualType}`,
419+
{ fieldName, expectedType, actualType, value }
420+
);
421+
}
422+
break;
423+
424+
// Email validation
425+
case 'Email':
426+
if (actualType !== 'string') {
427+
throw new ValidationError(
428+
`Row ${rowIndex}: Field "${fieldName}" must be a string (Email), got ${actualType}`,
429+
{ fieldName, expectedType, actualType, value }
430+
);
431+
}
432+
// Basic RFC 5322 email validation
433+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
434+
throw new ValidationError(
435+
`Row ${rowIndex}: Field "${fieldName}" must be a valid email address, got: ${value}`,
436+
{ fieldName, value }
437+
);
438+
}
439+
break;
440+
441+
// URL validation
442+
case 'URL':
443+
if (actualType !== 'string') {
444+
throw new ValidationError(
445+
`Row ${rowIndex}: Field "${fieldName}" must be a string (URL), got ${actualType}`,
446+
{ fieldName, expectedType, actualType, value }
447+
);
448+
}
449+
try {
450+
new URL(value);
451+
} catch {
452+
throw new ValidationError(
453+
`Row ${rowIndex}: Field "${fieldName}" must be a valid URL, got: ${value}`,
379454
{ fieldName, value }
380455
);
381456
}
382457
break;
383458

384-
case 'string':
459+
// Phone validation (flexible international format)
460+
case 'Phone':
385461
if (actualType !== 'string') {
386462
throw new ValidationError(
387-
`Row ${rowIndex}: Field "${fieldName}" must be a string, got ${actualType}`,
463+
`Row ${rowIndex}: Field "${fieldName}" must be a string (Phone), got ${actualType}`,
388464
{ fieldName, expectedType, actualType, value }
389465
);
390466
}
467+
// Basic phone validation: digits, spaces, +, -, (, )
468+
if (!/^[\d\s+\-()]+$/.test(value)) {
469+
throw new ValidationError(
470+
`Row ${rowIndex}: Field "${fieldName}" must be a valid phone number, got: ${value}`,
471+
{ fieldName, value }
472+
);
473+
}
474+
break;
475+
476+
// Text-based types (no additional validation beyond string check)
477+
case 'Text':
478+
case 'Name':
479+
case 'Address':
480+
case 'Color':
481+
case 'Enum':
482+
case 'Ref':
483+
case 'Image':
484+
case 'File':
485+
case 'Drawing':
486+
case 'Signature':
487+
case 'ChangeLocation':
488+
case 'Show':
489+
if (actualType !== 'string') {
490+
throw new ValidationError(
491+
`Row ${rowIndex}: Field "${fieldName}" must be a string (${expectedType}), got ${actualType}`,
492+
{ fieldName, expectedType, actualType, value }
493+
);
494+
}
495+
break;
496+
497+
default:
498+
// Unknown type - skip validation
391499
break;
392500
}
393501
}
394502

395503
/**
396-
* Validate enum value
504+
* Validate enum/enumList values against allowed values
397505
*/
398506
private validateEnum(
399507
rowIndex: number,
400508
fieldName: string,
509+
fieldType: string,
401510
allowedValues: string[],
402511
value: any
403512
): void {
404-
if (!allowedValues.includes(value)) {
405-
throw new ValidationError(
406-
`Row ${rowIndex}: Field "${fieldName}" must be one of: ${allowedValues.join(', ')}. Got: ${value}`,
407-
{ fieldName, allowedValues, value }
408-
);
513+
// EnumList: validate array of values
514+
if (fieldType === 'EnumList') {
515+
if (!Array.isArray(value)) {
516+
throw new ValidationError(
517+
`Row ${rowIndex}: Field "${fieldName}" must be an array for EnumList type`,
518+
{ fieldName, value }
519+
);
520+
}
521+
const invalidValues = value.filter((v) => !allowedValues.includes(v));
522+
if (invalidValues.length > 0) {
523+
throw new ValidationError(
524+
`Row ${rowIndex}: Field "${fieldName}" contains invalid values: ${invalidValues.join(', ')}. Allowed: ${allowedValues.join(', ')}`,
525+
{ fieldName, allowedValues, invalidValues }
526+
);
527+
}
528+
} else {
529+
// Enum: validate single value
530+
if (!allowedValues.includes(value)) {
531+
throw new ValidationError(
532+
`Row ${rowIndex}: Field "${fieldName}" must be one of: ${allowedValues.join(', ')}. Got: ${value}`,
533+
{ fieldName, allowedValues, value }
534+
);
535+
}
409536
}
410537
}
411538
}

0 commit comments

Comments
 (0)