Skip to content

Commit cadc4bd

Browse files
feat: add schema field constraints
1 parent 01da984 commit cadc4bd

8 files changed

Lines changed: 140 additions & 10 deletions

File tree

docs/API.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ intentionally stable so renaming a site does not break consumers.
181181
schema. Collection slugs remain stable. Schema changes are rejected with a
182182
`422 validation_failed` response if any existing entry would become invalid.
183183

184+
Collection fields may define optional constraints:
185+
186+
- `minLength` and `maxLength` for `string`, `text`, and `url` fields.
187+
- `min` and `max` for `number` fields.
188+
189+
Constraints are validated when schemas are created or edited and enforced for
190+
all subsequent entry creation and updates.
191+
184192
## 🔐 Authentication Endpoints
185193

186194
### Register User

internal/models/field_types.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ func IsValidFieldType(fieldType string) bool {
3434
}
3535

3636
type Field struct {
37-
Name string `json:"name"`
38-
Type FieldType `json:"type"`
39-
Optional bool `json:"optional,omitempty"`
37+
Name string `json:"name"`
38+
Type FieldType `json:"type"`
39+
Optional bool `json:"optional,omitempty"`
40+
MinLength *int `json:"minLength,omitempty"`
41+
MaxLength *int `json:"maxLength,omitempty"`
42+
Min *float64 `json:"min,omitempty"`
43+
Max *float64 `json:"max,omitempty"`
4044
}

internal/services/validation.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"barecms/internal/models"
55
"encoding/json"
66
"fmt"
7+
"math"
78
"net/url"
89
"strconv"
910
"strings"
@@ -41,7 +42,7 @@ func validateEntryData(data json.RawMessage, fields []models.Field) error {
4142
errors[field.Name] = "type does not match collection schema"
4243
continue
4344
}
44-
if message := validateFieldValue(field.Type, value.Value); message != "" {
45+
if message := validateFieldValue(field, value.Value); message != "" {
4546
errors[field.Name] = message
4647
}
4748
}
@@ -56,16 +57,23 @@ func validateEntryData(data json.RawMessage, fields []models.Field) error {
5657
return nil
5758
}
5859

59-
func validateFieldValue(fieldType models.FieldType, value any) string {
60+
func validateFieldValue(field models.Field, value any) string {
6061
text, ok := value.(string)
6162
if !ok {
6263
return "must be a string value"
6364
}
64-
switch fieldType {
65+
switch field.Type {
6566
case models.FieldTypeNumber:
66-
if _, err := strconv.ParseFloat(text, 64); err != nil {
67+
number, err := strconv.ParseFloat(text, 64)
68+
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
6769
return "must be a number"
6870
}
71+
if field.Min != nil && number < *field.Min {
72+
return fmt.Sprintf("must be at least %g", *field.Min)
73+
}
74+
if field.Max != nil && number > *field.Max {
75+
return fmt.Sprintf("must be at most %g", *field.Max)
76+
}
6977
case models.FieldTypeBoolean:
7078
if _, err := strconv.ParseBool(text); err != nil {
7179
return "must be true or false"
@@ -80,6 +88,12 @@ func validateFieldValue(fieldType models.FieldType, value any) string {
8088
return "must be a valid URL"
8189
}
8290
}
91+
if field.MinLength != nil && len([]rune(text)) < *field.MinLength {
92+
return fmt.Sprintf("must contain at least %d characters", *field.MinLength)
93+
}
94+
if field.MaxLength != nil && len([]rune(text)) > *field.MaxLength {
95+
return fmt.Sprintf("must contain at most %d characters", *field.MaxLength)
96+
}
8397
return ""
8498
}
8599

@@ -104,6 +118,25 @@ func validateCollectionSchema(name string, fields []models.Field) error {
104118
if !models.IsValidFieldType(string(field.Type)) {
105119
problems[key+".type"] = "is invalid"
106120
}
121+
stringLike := field.Type == models.FieldTypeString || field.Type == models.FieldTypeText || field.Type == models.FieldTypeURL
122+
if (field.MinLength != nil || field.MaxLength != nil) && !stringLike {
123+
problems[key+".length"] = "is only supported for string, text, and URL fields"
124+
}
125+
if field.MinLength != nil && *field.MinLength < 0 {
126+
problems[key+".minLength"] = "must be zero or greater"
127+
}
128+
if field.MaxLength != nil && *field.MaxLength < 0 {
129+
problems[key+".maxLength"] = "must be zero or greater"
130+
}
131+
if field.MinLength != nil && field.MaxLength != nil && *field.MinLength > *field.MaxLength {
132+
problems[key+".maxLength"] = "must be greater than or equal to minLength"
133+
}
134+
if (field.Min != nil || field.Max != nil) && field.Type != models.FieldTypeNumber {
135+
problems[key+".range"] = "is only supported for number fields"
136+
}
137+
if field.Min != nil && field.Max != nil && *field.Min > *field.Max {
138+
problems[key+".max"] = "must be greater than or equal to min"
139+
}
107140
}
108141
if len(problems) > 0 {
109142
return &ValidationError{Fields: problems}

internal/services/validation_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,40 @@ func TestValidateCollectionSchemaRejectsInvalidDefinitions(t *testing.T) {
4848
}
4949
}
5050
}
51+
52+
func TestValidateEntryDataEnforcesLengthAndRangeConstraints(t *testing.T) {
53+
minLength, maxLength := 3, 5
54+
min, max := 1.0, 10.0
55+
fields := []models.Field{
56+
{Name: "title", Type: models.FieldTypeString, MinLength: &minLength, MaxLength: &maxLength},
57+
{Name: "rating", Type: models.FieldTypeNumber, Min: &min, Max: &max},
58+
}
59+
invalid := json.RawMessage(`{"title":{"value":"ab","type":"string"},"rating":{"value":"11","type":"number"}}`)
60+
var validationError *ValidationError
61+
if !errors.As(validateEntryData(invalid, fields), &validationError) {
62+
t.Fatal("expected constraint validation error")
63+
}
64+
if validationError.Fields["title"] == "" || validationError.Fields["rating"] == "" {
65+
t.Fatalf("missing constraint errors: %+v", validationError.Fields)
66+
}
67+
valid := json.RawMessage(`{"title":{"value":"hello","type":"string"},"rating":{"value":"10","type":"number"}}`)
68+
if err := validateEntryData(valid, fields); err != nil {
69+
t.Fatalf("valid constrained entry rejected: %v", err)
70+
}
71+
}
72+
73+
func TestValidateCollectionSchemaRejectsInvalidConstraints(t *testing.T) {
74+
minLength, maxLength := 5, 2
75+
min, max := 10.0, 1.0
76+
err := validateCollectionSchema("Posts", []models.Field{
77+
{Name: "title", Type: models.FieldTypeString, MinLength: &minLength, MaxLength: &maxLength},
78+
{Name: "rating", Type: models.FieldTypeNumber, Min: &min, Max: &max},
79+
})
80+
var validationError *ValidationError
81+
if !errors.As(err, &validationError) {
82+
t.Fatal("expected invalid constraints to be rejected")
83+
}
84+
if validationError.Fields["fields.0.maxLength"] == "" || validationError.Fields["fields.1.max"] == "" {
85+
t.Fatalf("missing schema errors: %+v", validationError.Fields)
86+
}
87+
}

roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ The basics every CMS needs. Without these, MCP positioning means nothing.
4343
- [ ] **Input validation and structured error responses**
4444
- [x] Required, schema, primitive type, URL, and date checks for entries (`feature/entry-validation`)
4545
- [x] Field-level `422 validation_failed` response shape
46-
- [ ] Length and numeric range constraints in collection schemas
46+
- [x] Length and numeric range constraints in collection schemas (`feature/schema-constraints`)
4747
- [x] Frontend renders accessible field-level errors (`feature/field-validation-ui`)
4848
- [ ] **API stability**
4949
- Lock response shapes for sites, collections, entries

ui/src/components/modals/CreateCollectionModal.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
2222
const [newFieldName, setNewFieldName] = useState("");
2323
const [newFieldType, setNewFieldType] = useState<FieldType>(FieldType.STRING);
2424
const [newFieldOptional, setNewFieldOptional] = useState<boolean>(false)
25+
const [newMinLength, setNewMinLength] = useState("");
26+
const [newMaxLength, setNewMaxLength] = useState("");
27+
const [newMin, setNewMin] = useState("");
28+
const [newMax, setNewMax] = useState("");
2529
const [error, setError] = useState<string | null>(null);
2630
const [fieldsError, setFieldsError] = useState<string | null>(null);
2731
const [serverErrors, setServerErrors] = useState<Record<string, string>>({});
@@ -48,6 +52,14 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
4852
setNewFieldOptional(e.target.checked)
4953
}
5054

55+
const handleNewFieldTypeChange = (type: FieldType) => {
56+
setNewFieldType(type);
57+
setNewMinLength("");
58+
setNewMaxLength("");
59+
setNewMin("");
60+
setNewMax("");
61+
};
62+
5163
const addField = () => {
5264
if (newFieldName.trim() === "") {
5365
setFieldsError("Field name cannot be empty.");
@@ -66,12 +78,20 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
6678
{
6779
name: newFieldName.trim().toLowerCase(),
6880
type: newFieldType,
69-
optional: newFieldOptional
81+
optional: newFieldOptional,
82+
minLength: newMinLength === "" ? undefined : Number(newMinLength),
83+
maxLength: newMaxLength === "" ? undefined : Number(newMaxLength),
84+
min: newMin === "" ? undefined : Number(newMin),
85+
max: newMax === "" ? undefined : Number(newMax),
7086
},
7187
]);
7288
setNewFieldName("");
7389
setNewFieldType(FieldType.STRING);
7490
setNewFieldOptional(false);
91+
setNewMinLength("");
92+
setNewMaxLength("");
93+
setNewMin("");
94+
setNewMax("");
7595
};
7696

7797
const removeField = (index: number) => {
@@ -148,6 +168,10 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
148168
<div key={index} className="flex items-center mb-2">
149169
<p className="mr-2">
150170
{field.name} ({field.type}{field.optional && ', optional'})
171+
{field.minLength !== undefined && ` · min ${field.minLength} chars`}
172+
{field.maxLength !== undefined && ` · max ${field.maxLength} chars`}
173+
{field.min !== undefined && ` · min ${field.min}`}
174+
{field.max !== undefined && ` · max ${field.max}`}
151175
</p>
152176
{(serverErrors[`fields.${index}.name`] || serverErrors[`fields.${index}.type`]) && (
153177
<p role="alert" className="text-sm text-error mr-2">
@@ -173,7 +197,7 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
173197
<select
174198
className="select select-bordered"
175199
value={newFieldType}
176-
onChange={(e) => setNewFieldType(e.target.value as FieldType)}
200+
onChange={(e) => handleNewFieldTypeChange(e.target.value as FieldType)}
177201
>
178202
{VALID_FIELD_TYPES.map((type: FieldType) => (
179203
<option key={type} value={type}>
@@ -195,6 +219,18 @@ const CreateCollectionModal: React.FC<CreateCollectionModalProps> = ({
195219
/>
196220
Optional
197221
</label>
222+
{[FieldType.STRING, FieldType.TEXT, FieldType.URL].includes(newFieldType) && (
223+
<div className="grid grid-cols-2 gap-2 mb-2">
224+
<input type="number" min="0" className="input input-bordered" placeholder="Min length" value={newMinLength} onChange={(event) => setNewMinLength(event.target.value)} />
225+
<input type="number" min="0" className="input input-bordered" placeholder="Max length" value={newMaxLength} onChange={(event) => setNewMaxLength(event.target.value)} />
226+
</div>
227+
)}
228+
{newFieldType === FieldType.NUMBER && (
229+
<div className="grid grid-cols-2 gap-2 mb-2">
230+
<input type="number" className="input input-bordered" placeholder="Minimum" value={newMin} onChange={(event) => setNewMin(event.target.value)} />
231+
<input type="number" className="input input-bordered" placeholder="Maximum" value={newMax} onChange={(event) => setNewMax(event.target.value)} />
232+
</div>
233+
)}
198234
</div>
199235
{fieldsError && <p className="text-red-500 mt-2">{fieldsError}</p>}
200236
{serverErrors.fields && <p role="alert" className="text-sm text-error mt-2">{serverErrors.fields}</p>}

ui/src/components/modals/CreateEntryModal.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ const CreateEntryModal: React.FC<CreateEntryModalProps> = ({
115115
onChange={handleInputChange}
116116
className="input input-bordered w-full"
117117
required={!field.optional}
118+
minLength={field.minLength}
119+
maxLength={field.maxLength}
118120
{...accessibilityProps(field)}
119121
/>
120122
);
@@ -128,6 +130,8 @@ const CreateEntryModal: React.FC<CreateEntryModalProps> = ({
128130
onChange={handleInputChange}
129131
className="input input-bordered w-full"
130132
required={!field.optional}
133+
minLength={field.minLength}
134+
maxLength={field.maxLength}
131135
{...accessibilityProps(field)}
132136
/>
133137
);
@@ -140,6 +144,8 @@ const CreateEntryModal: React.FC<CreateEntryModalProps> = ({
140144
onChange={handleInputChange}
141145
className="textarea textarea-bordered w-full"
142146
required={!field.optional}
147+
minLength={field.minLength}
148+
maxLength={field.maxLength}
143149
{...accessibilityProps(field)}
144150
/>
145151
);
@@ -153,6 +159,8 @@ const CreateEntryModal: React.FC<CreateEntryModalProps> = ({
153159
onChange={handleInputChange}
154160
className="input input-bordered w-full"
155161
required={!field.optional}
162+
min={field.min}
163+
max={field.max}
156164
{...accessibilityProps(field)}
157165
/>
158166
);

ui/src/types/fields.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ export interface Field {
1616
name: string;
1717
type: FieldType;
1818
optional?: boolean;
19+
minLength?: number;
20+
maxLength?: number;
21+
min?: number;
22+
max?: number;
1923
}

0 commit comments

Comments
 (0)