Skip to content

Commit 25a9747

Browse files
authored
Merge pull request #441 from objectstack-ai/copilot/add-field-name-format-constraint
2 parents 4551a72 + bf8f320 commit 25a9747

File tree

5 files changed

+133
-5
lines changed

5 files changed

+133
-5
lines changed

content/docs/references/data/object.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const result = ApiMethodSchema.parse(data);
8585
| **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) |
8686
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
8787
| **tableName** | `string` | optional | Physical table/collection name in the target datasource |
88-
| **fields** | `Record<string, object>` || Field definitions map |
88+
| **fields** | `Record<string, object>` || Field definitions map. Keys must be snake_case identifiers. |
8989
| **indexes** | `object[]` | optional | Database performance indexes |
9090
| **tenancy** | `object` | optional | Multi-tenancy configuration for SaaS applications |
9191
| **softDelete** | `object` | optional | Soft delete (trash/recycle bin) configuration |

examples/msw-react-crud/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export const TaskObject = {
2020
id: { name: 'id', label: 'ID', type: 'text', required: true },
2121
subject: { name: 'subject', label: 'Subject', type: 'text', required: true },
2222
priority: { name: 'priority', label: 'Priority', type: 'number', defaultValue: 5 },
23-
isCompleted: { name: 'isCompleted', label: 'Completed', type: 'boolean', defaultValue: false },
24-
createdAt: { name: 'createdAt', label: 'Created At', type: 'datetime' }
23+
is_completed: { name: 'is_completed', label: 'Completed', type: 'boolean', defaultValue: false },
24+
created_at: { name: 'created_at', label: 'Created At', type: 'datetime' }
2525
}
2626
};
2727

packages/spec/json-schema/data/Object.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -913,7 +913,10 @@
913913
],
914914
"additionalProperties": false
915915
},
916-
"description": "Field definitions map"
916+
"propertyNames": {
917+
"pattern": "^[a-z_][a-z0-9_]*$"
918+
},
919+
"description": "Field definitions map. Keys must be snake_case identifiers."
917920
},
918921
"indexes": {
919922
"type": "array",

packages/spec/src/data/object.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,129 @@ describe('ObjectSchema', () => {
136136

137137
expect(() => ObjectSchema.parse(objectWithFields)).not.toThrow();
138138
});
139+
140+
it('should enforce snake_case for field names', () => {
141+
// Valid snake_case field names
142+
const validFieldNames = ['first_name', 'last_name', 'email', 'company_name', 'annual_revenue', '_system_id'];
143+
144+
validFieldNames.forEach(fieldName => {
145+
const obj = {
146+
name: 'test_object',
147+
fields: {
148+
[fieldName]: {
149+
type: 'text' as const,
150+
label: 'Test Field',
151+
},
152+
},
153+
};
154+
expect(() => ObjectSchema.parse(obj)).not.toThrow();
155+
});
156+
});
157+
158+
it('should reject PascalCase field names', () => {
159+
const invalidObject = {
160+
name: 'lead',
161+
fields: {
162+
FirstName: {
163+
type: 'text' as const,
164+
label: '名',
165+
},
166+
},
167+
};
168+
169+
expect(() => ObjectSchema.parse(invalidObject)).toThrow();
170+
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
171+
});
172+
173+
it('should reject camelCase field names', () => {
174+
const invalidObject = {
175+
name: 'lead',
176+
fields: {
177+
firstName: {
178+
type: 'text' as const,
179+
label: 'First Name',
180+
},
181+
},
182+
};
183+
184+
expect(() => ObjectSchema.parse(invalidObject)).toThrow();
185+
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
186+
});
187+
188+
it('should reject kebab-case field names', () => {
189+
const invalidObject = {
190+
name: 'lead',
191+
fields: {
192+
'first-name': {
193+
type: 'text' as const,
194+
label: 'First Name',
195+
},
196+
},
197+
};
198+
199+
expect(() => ObjectSchema.parse(invalidObject)).toThrow();
200+
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
201+
});
202+
203+
it('should reject field names with spaces', () => {
204+
const invalidObject = {
205+
name: 'lead',
206+
fields: {
207+
'first name': {
208+
type: 'text' as const,
209+
label: 'First Name',
210+
},
211+
},
212+
};
213+
214+
expect(() => ObjectSchema.parse(invalidObject)).toThrow();
215+
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
216+
});
217+
218+
it('should reject field names starting with numbers', () => {
219+
const invalidObject = {
220+
name: 'lead',
221+
fields: {
222+
'123field': {
223+
type: 'text' as const,
224+
label: 'Field',
225+
},
226+
},
227+
};
228+
229+
expect(() => ObjectSchema.parse(invalidObject)).toThrow();
230+
expect(() => ObjectSchema.parse(invalidObject)).toThrow(/Field names must be lowercase snake_case/);
231+
});
232+
233+
it('should reject mixed-case field names like in AI-generated objects', () => {
234+
// This is the exact problem from the issue
235+
const aiGeneratedObject = {
236+
name: 'lead',
237+
label: '线索',
238+
fields: {
239+
FirstName: {
240+
type: 'text' as const,
241+
label: '名',
242+
maxLength: 40,
243+
},
244+
LastName: {
245+
type: 'text' as const,
246+
label: '姓',
247+
required: true,
248+
maxLength: 80,
249+
},
250+
Company: {
251+
type: 'text' as const,
252+
label: '公司',
253+
required: true,
254+
maxLength: 255,
255+
},
256+
},
257+
};
258+
259+
expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow();
260+
expect(() => ObjectSchema.parse(aiGeneratedObject)).toThrow(/Field names must be lowercase snake_case/);
261+
});
139262
});
140263

141264
describe('Object Metadata', () => {

packages/spec/src/data/object.zod.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,9 @@ const ObjectSchemaBase = z.object({
217217
/**
218218
* Data Model
219219
*/
220-
fields: z.record(FieldSchema).describe('Field definitions map'),
220+
fields: z.record(z.string().regex(/^[a-z_][a-z0-9_]*$/, {
221+
message: 'Field names must be lowercase snake_case (e.g., "first_name", "company", "annual_revenue")',
222+
}), FieldSchema).describe('Field definitions map. Keys must be snake_case identifiers.'),
221223
indexes: z.array(IndexSchema).optional().describe('Database performance indexes'),
222224

223225
/**

0 commit comments

Comments
 (0)