diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index b9a67b513..174338899 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -85,7 +85,7 @@ const result = ApiMethodSchema.parse(data); | **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) | | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | | **tableName** | `string` | optional | Physical table/collection name in the target datasource | -| **fields** | `Record` | ✅ | Field definitions map | +| **fields** | `Record` | ✅ | Field definitions map. Keys must be snake_case identifiers. | | **indexes** | `object[]` | optional | Database performance indexes | | **tenancy** | `object` | optional | Multi-tenancy configuration for SaaS applications | | **softDelete** | `object` | optional | Soft delete (trash/recycle bin) configuration | diff --git a/examples/msw-react-crud/objectstack.config.ts b/examples/msw-react-crud/objectstack.config.ts index 9334ecec8..0896fa296 100644 --- a/examples/msw-react-crud/objectstack.config.ts +++ b/examples/msw-react-crud/objectstack.config.ts @@ -20,8 +20,8 @@ export const TaskObject = { id: { name: 'id', label: 'ID', type: 'text', required: true }, subject: { name: 'subject', label: 'Subject', type: 'text', required: true }, priority: { name: 'priority', label: 'Priority', type: 'number', defaultValue: 5 }, - isCompleted: { name: 'isCompleted', label: 'Completed', type: 'boolean', defaultValue: false }, - createdAt: { name: 'createdAt', label: 'Created At', type: 'datetime' } + is_completed: { name: 'is_completed', label: 'Completed', type: 'boolean', defaultValue: false }, + created_at: { name: 'created_at', label: 'Created At', type: 'datetime' } } }; diff --git a/packages/spec/json-schema/data/Object.json b/packages/spec/json-schema/data/Object.json index e4a43d83c..6bccad7b1 100644 --- a/packages/spec/json-schema/data/Object.json +++ b/packages/spec/json-schema/data/Object.json @@ -913,7 +913,10 @@ ], "additionalProperties": false }, - "description": "Field definitions map" + "propertyNames": { + "pattern": "^[a-z_][a-z0-9_]*$" + }, + "description": "Field definitions map. Keys must be snake_case identifiers." }, "indexes": { "type": "array", diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 19185f8fc..2692bc146 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -136,6 +136,129 @@ describe('ObjectSchema', () => { expect(() => ObjectSchema.parse(objectWithFields)).not.toThrow(); }); + + it('should enforce snake_case for field names', () => { + // Valid snake_case field names + const validFieldNames = ['first_name', 'last_name', 'email', 'company_name', 'annual_revenue', '_system_id']; + + validFieldNames.forEach(fieldName => { + const obj = { + name: 'test_object', + fields: { + [fieldName]: { + type: 'text' as const, + label: 'Test Field', + }, + }, + }; + expect(() => ObjectSchema.parse(obj)).not.toThrow(); + }); + }); + + it('should reject PascalCase field names', () => { + const invalidObject = { + name: 'lead', + fields: { + FirstName: { + type: 'text' as const, + label: '名', + }, + }, + }; + + expect(() => ObjectSchema.parse(invalidObject)).toThrow(); + expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/); + }); + + it('should reject camelCase field names', () => { + const invalidObject = { + name: 'lead', + fields: { + firstName: { + type: 'text' as const, + label: 'First Name', + }, + }, + }; + + expect(() => ObjectSchema.parse(invalidObject)).toThrow(); + expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/); + }); + + it('should reject kebab-case field names', () => { + const invalidObject = { + name: 'lead', + fields: { + 'first-name': { + type: 'text' as const, + label: 'First Name', + }, + }, + }; + + expect(() => ObjectSchema.parse(invalidObject)).toThrow(); + expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/); + }); + + it('should reject field names with spaces', () => { + const invalidObject = { + name: 'lead', + fields: { + 'first name': { + type: 'text' as const, + label: 'First Name', + }, + }, + }; + + expect(() => ObjectSchema.parse(invalidObject)).toThrow(); + expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/); + }); + + it('should reject field names starting with numbers', () => { + const invalidObject = { + name: 'lead', + fields: { + '123field': { + type: 'text' as const, + label: 'Field', + }, + }, + }; + + expect(() => ObjectSchema.parse(invalidObject)).toThrow(); + expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/); + }); + + it('should reject mixed-case field names like in AI-generated objects', () => { + // This is the exact problem from the issue + const aiGeneratedObject = { + name: 'lead', + label: '线索', + fields: { + FirstName: { + type: 'text' as const, + label: '名', + maxLength: 40, + }, + LastName: { + type: 'text' as const, + label: '姓', + required: true, + maxLength: 80, + }, + Company: { + type: 'text' as const, + label: '公司', + required: true, + maxLength: 255, + }, + }, + }; + + expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow(); + expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow(/Field names must be lowercase snake_case/); + }); }); describe('Object Metadata', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e4f239c1c..dc4796961 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -217,7 +217,9 @@ const ObjectSchemaBase = z.object({ /** * Data Model */ - fields: z.record(FieldSchema).describe('Field definitions map'), + fields: z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/, { + message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")', + }), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'), indexes: z.array(IndexSchema).optional().describe('Database performance indexes'), /**