Skip to content

Commit 4fb3e1f

Browse files
authored
Merge pull request #2512 from keboola/devin/PSGO-94-1768850059-conditional-field-requirements
feat(schema): add conditional field requirements support via options.dependencies
2 parents d1c2456 + 7037c4f commit 4fb3e1f

14 files changed

Lines changed: 686 additions & 2 deletions

File tree

internal/pkg/encoding/json/schema/schema.go

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,35 +125,118 @@ func ValidateContent(schema []byte, content *orderedmap.OrderedMap) error {
125125
return nil
126126
}
127127

128+
// conditionalRequirement represents a field that is conditionally required based on options.dependencies.
129+
type conditionalRequirement struct {
130+
fieldName string // The field that is conditionally required
131+
dependencies map[string]any // The dependency conditions (e.g., {"append_date": 1})
132+
}
133+
128134
func NormalizeSchema(schema []byte) ([]byte, error) {
129135
// Decode JSON
130136
m := orderedmap.New()
131137
if err := json.Decode(schema, &m); err != nil {
132138
return nil, err
133139
}
134140

141+
// Collect conditional requirements per parent object path
142+
// Key is the string representation of the parent path (the object containing the properties)
143+
conditionalReqs := make(map[string][]conditionalRequirement)
144+
135145
m.VisitAllRecursive(func(path orderedmap.Path, value any, parent any) {
146+
lastStep := path.Last()
147+
136148
// Required field in a JSON schema should be an array of required nested fields.
137149
// But, for historical reasons, in Keboola components, "required: true" and "required: false" are also used.
138150
// In the UI, this causes the drop-down list to not have an empty value, so the error should be ignored.
139-
if path.Last() == orderedmap.MapStep("required") {
151+
if lastStep == orderedmap.MapStep("required") {
140152
if _, ok := value.(bool); ok {
141153
if parentMap, ok := parent.(*orderedmap.OrderedMap); ok {
142154
parentMap.Delete("required")
143155
}
144156
}
157+
return
145158
}
146159

147160
// Empty enums are removed, we're using those for asynchronously loaded enums.
148-
if path.Last() == orderedmap.MapStep("enum") {
161+
if lastStep == orderedmap.MapStep("enum") {
149162
if arr, ok := value.([]any); ok && len(arr) == 0 {
150163
if parentMap, ok := parent.(*orderedmap.OrderedMap); ok {
151164
parentMap.Delete("enum")
152165
}
153166
}
167+
return
168+
}
169+
170+
// Handle options.dependencies - collect conditional requirements
171+
// Path pattern: .../properties/<fieldName>/options/dependencies
172+
if lastStep != orderedmap.MapStep("dependencies") {
173+
return
174+
}
175+
pathLen := len(path)
176+
if pathLen < 4 {
177+
return
178+
}
179+
if path[pathLen-2] != orderedmap.MapStep("options") {
180+
return
154181
}
182+
fieldStep, ok := path[pathLen-3].(orderedmap.MapStep)
183+
if !ok {
184+
return
185+
}
186+
if path[pathLen-4] != orderedmap.MapStep("properties") {
187+
return
188+
}
189+
depsMap, ok := value.(*orderedmap.OrderedMap)
190+
if !ok {
191+
return
192+
}
193+
deps := make(map[string]any)
194+
for _, key := range depsMap.Keys() {
195+
depValue, _ := depsMap.Get(key)
196+
deps[key] = depValue
197+
}
198+
if len(deps) == 0 {
199+
return
200+
}
201+
parentPath := path[:pathLen-4]
202+
parentPathStr := parentPath.String()
203+
conditionalReqs[parentPathStr] = append(conditionalReqs[parentPathStr], conditionalRequirement{
204+
fieldName: fieldStep.Key(),
205+
dependencies: deps,
206+
})
155207
})
156208

209+
// Process conditional requirements: remove from required arrays and add if/then/else constructs
210+
for parentPathStr, reqs := range conditionalReqs {
211+
parentObj := getObjectAtPath(m, parentPathStr)
212+
if parentObj == nil {
213+
continue
214+
}
215+
216+
// Remove conditionally required fields from the required array
217+
removeConditionalFieldsFromRequired(parentObj, reqs)
218+
219+
// Generate if/then/else constructs for each conditional requirement
220+
allOfItems := make([]any, 0, len(reqs))
221+
for _, req := range reqs {
222+
ifThenElse := buildIfThenElse(req)
223+
if ifThenElse != nil {
224+
allOfItems = append(allOfItems, ifThenElse)
225+
}
226+
}
227+
228+
// Add allOf with if/then/else constructs to the parent object
229+
if len(allOfItems) > 0 {
230+
// Check if allOf already exists
231+
if existingAllOf, found := parentObj.Get("allOf"); found {
232+
if existingArr, ok := existingAllOf.([]any); ok {
233+
allOfItems = append(existingArr, allOfItems...)
234+
}
235+
}
236+
parentObj.Set("allOf", allOfItems)
237+
}
238+
}
239+
157240
// Encode back to JSON
158241
normalized, err := json.Encode(m, false)
159242
if err != nil {
@@ -163,6 +246,100 @@ func NormalizeSchema(schema []byte) ([]byte, error) {
163246
return normalized, nil
164247
}
165248

249+
// removeConditionalFieldsFromRequired removes conditionally required fields from the required array.
250+
func removeConditionalFieldsFromRequired(parentObj *orderedmap.OrderedMap, reqs []conditionalRequirement) {
251+
requiredVal, found := parentObj.Get("required")
252+
if !found {
253+
return
254+
}
255+
requiredArr, ok := requiredVal.([]any)
256+
if !ok {
257+
return
258+
}
259+
260+
conditionalFields := make(map[string]bool)
261+
for _, req := range reqs {
262+
conditionalFields[req.fieldName] = true
263+
}
264+
265+
newRequired := make([]any, 0, len(requiredArr))
266+
for _, field := range requiredArr {
267+
fieldStr, ok := field.(string)
268+
if !ok || !conditionalFields[fieldStr] {
269+
newRequired = append(newRequired, field)
270+
}
271+
}
272+
273+
if len(newRequired) > 0 {
274+
parentObj.Set("required", newRequired)
275+
} else {
276+
parentObj.Delete("required")
277+
}
278+
}
279+
280+
// getObjectAtPath returns the orderedmap at the given path string.
281+
func getObjectAtPath(m *orderedmap.OrderedMap, pathStr string) *orderedmap.OrderedMap {
282+
if pathStr == "" {
283+
return m
284+
}
285+
286+
// Parse path string and navigate to the object
287+
// Path format: key1.key2.key3 (dot-separated)
288+
parts := strings.Split(pathStr, ".")
289+
current := m
290+
for _, part := range parts {
291+
if part == "" {
292+
continue
293+
}
294+
val, found := current.Get(part)
295+
if !found {
296+
return nil
297+
}
298+
nextMap, ok := val.(*orderedmap.OrderedMap)
299+
if !ok {
300+
return nil
301+
}
302+
current = nextMap
303+
}
304+
return current
305+
}
306+
307+
// buildIfThenElse creates an if/then/else construct for a conditional requirement.
308+
func buildIfThenElse(req conditionalRequirement) *orderedmap.OrderedMap {
309+
if len(req.dependencies) == 0 {
310+
return nil
311+
}
312+
313+
// Build the "if" condition properties
314+
ifProperties := orderedmap.New()
315+
for depField, depValue := range req.dependencies {
316+
condition := orderedmap.New()
317+
// Handle array values (e.g., "protocol": ["FTP", "FTPS"]) using enum
318+
// Handle single values using const
319+
if arr, ok := depValue.([]any); ok {
320+
condition.Set("enum", arr)
321+
} else {
322+
condition.Set("const", depValue)
323+
}
324+
ifProperties.Set(depField, condition)
325+
}
326+
327+
// Build the "if" clause
328+
ifClause := orderedmap.New()
329+
ifClause.Set("properties", ifProperties)
330+
331+
// Build the "then" clause with required field
332+
thenClause := orderedmap.New()
333+
thenClause.Set("required", []any{req.fieldName})
334+
335+
// Build the complete if/then construct
336+
result := orderedmap.New()
337+
result.Set("if", ifClause)
338+
result.Set("then", thenClause)
339+
340+
return result
341+
}
342+
166343
func validateDocument(schemaStr []byte, document *orderedmap.OrderedMap) error {
167344
schema, err := compileSchema(schemaStr, false)
168345
if err != nil {

0 commit comments

Comments
 (0)