Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions lib/Db/SchemaMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -1491,10 +1491,9 @@ private function determineFacetTypeFromProperty(array $property): string
* Resolve schema composition by merging referenced schemas
*
* This method implements JSON Schema composition patterns conforming to the specification:
* 1. Handles 'extend' (deprecated) for backward compatibility
* 2. Handles 'allOf' - instance must validate against ALL schemas (multiple inheritance)
* 3. Handles 'oneOf' - instance must validate against EXACTLY ONE schema
* 4. Handles 'anyOf' - instance must validate against AT LEAST ONE schema
* 1. Handles 'allOf' - instance must validate against ALL schemas (multiple inheritance)
* 2. Handles 'oneOf' - instance must validate against EXACTLY ONE schema
* 3. Handles 'anyOf' - instance must validate against AT LEAST ONE schema
*
* The method enforces the Liskov Substitution Principle:
* - Extended schemas can ONLY ADD constraints, never relax them
Expand Down
18 changes: 10 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@conduction/nextcloud-vue": "0.1.0-beta.14",
"@conduction/nextcloud-vue": "0.1.0-beta.16",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@nextcloud/axios": "^2.5.0",
Expand Down
6 changes: 6 additions & 0 deletions src/entities/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export class Schema implements TSchema {
public hardValidation: boolean
public maxDepth: number
public authorization?: Record<string, string[]>
public allOf?: string[]
public oneOf?: string[]
public anyOf?: string[]
public stats?: TSchema['stats']

constructor(schema: TSchema) {
Expand Down Expand Up @@ -58,6 +61,9 @@ export class Schema implements TSchema {
this.hardValidation = schema.hardValidation || false
this.maxDepth = schema.maxDepth || 0
this.authorization = schema.authorization || {}
this.allOf = schema.allOf
this.oneOf = schema.oneOf
this.anyOf = schema.anyOf
this.stats = schema.stats
}

Expand Down
4 changes: 3 additions & 1 deletion src/entities/schema/schema.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export type TSchema = {
hardValidation: boolean; // Whether hard validation is enabled
maxDepth: number; // Maximum depth of the schema
authorization?: Record<string, string[]>; // RBAC authorization configuration
extend?: string; // ID or UUID of the parent schema that this schema extends
allOf?: string[]; // Schema refs (id, UUID, or slug) that this schema must validate against (inheritance/extension via JSON Schema allOf)
oneOf?: string[]; // Schema refs where instance must validate against EXACTLY ONE (mutually exclusive options)
anyOf?: string[]; // Schema refs where instance must validate against AT LEAST ONE (flexible composition)
stats?: {
objects: {
total: number
Expand Down
55 changes: 52 additions & 3 deletions src/modals/object/ViewObject.vue
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,23 @@ export default {
return registerStore.registerItem
},
currentSchema() {
return schemaStore.schemaItem
const schema = schemaStore.schemaItem
if (!schema) return schema
const allOf = schema.allOf || []
if (!allOf.length) return schema
// Merge inherited properties from allOf parent schemas so extended schemas
// expose the full property set (own + inherited) in the form dialog.
const inherited = {}
for (const ref of allOf) {
const schemaId = typeof ref === 'object' ? ref.id : ref
const parentSchema = schemaStore.schemaList.find(s =>
s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId),
)
if (parentSchema?.properties) {
Object.assign(inherited, parentSchema.properties)
}
}
return { ...schema, properties: { ...inherited, ...(schema.properties || {}) } }
},
selectedPublishedCount() {
return this.selectedAttachments.filter((a) => {
Expand Down Expand Up @@ -1027,8 +1043,29 @@ export default {
},
// Watch for schema changes to re-initialize data
currentSchema: {
handler(newSchema) {
async handler(newSchema) {
console.info('Schema changed in ViewObject:', newSchema)

// The schema list endpoint returns un-resolved schemas — for schemas
// using composition (allOf/oneOf/anyOf) `properties` is empty until
// the detail endpoint merges in the parent's properties. When the
// active schema looks un-resolved, refetch via the detail endpoint
// and let the watcher fire again with merged properties.
if (newSchema?.id) {
const usesComposition = (newSchema.allOf?.length || 0) > 0
|| (newSchema.oneOf?.length || 0) > 0
|| (newSchema.anyOf?.length || 0) > 0
const propsCount = Object.keys(newSchema.properties || {}).length
if (usesComposition && propsCount === 0) {
try {
await schemaStore.getSchema(newSchema.id, { setItem: true })
return
} catch (error) {
console.warn('Failed to fetch resolved schema:', error)
}
}
}

if (newSchema && this.isNewObject) {
// Re-initialize data when schema becomes available for new objects
this.initializeData()
Expand Down Expand Up @@ -1065,7 +1102,7 @@ export default {
deep: true,
},
},
mounted() {
async mounted() {
// Debug: Log current state when modal opens
console.info('ViewObject mounted:', {
objectItem: objectStore.objectItem,
Expand All @@ -1074,6 +1111,18 @@ export default {
isNewObject: this.isNewObject,
})

// Refetch the active schema by id so the store holds the resolved version
// (with allOf/oneOf/anyOf composition merged in by the backend). The schema
// list endpoint returns raw schemas with empty properties for extended
// schemas — the detail endpoint resolves composition.
if (schemaStore.schemaItem?.id) {
try {
await schemaStore.getSchema(schemaStore.schemaItem.id, { setItem: true })
} catch (error) {
console.warn('Failed to fetch resolved schema:', error)
}
}

// Initialize data when modal opens
this.initializeData()
this.loadTitles()
Expand Down
19 changes: 18 additions & 1 deletion src/modals/schema/EditSchema.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { schemaStore, navigationStore, registerStore } from '../../store/store.j
:dialog-title="schemaStore.schemaItem?.id ? t('openregister', 'Edit Schema') : t('openregister', 'Add Schema')"
:available-schemas="computedAvailableSchemas"
:available-registers="computedAvailableRegisters"
:inherited-properties="computedInheritedProperties"
:user-groups="userGroups"
:loading-groups="loadingGroups"
:available-tags="availableTags"
Expand Down Expand Up @@ -86,6 +87,22 @@ export default {
label: register.title || register.name || register.id,
}))
},
computedInheritedProperties() {
const allOf = schemaStore.schemaItem?.allOf || []
if (!allOf.length) return {}

const merged = {}
for (const ref of allOf) {
const schemaId = typeof ref === 'object' ? ref.id : ref
const parentSchema = schemaStore.schemaList.find(s =>
s.id === schemaId || s.uuid === schemaId || s.slug === schemaId,
)
if (parentSchema?.properties) {
Object.assign(merged, parentSchema.properties)
}
}
return merged
},
},
mounted() {
this.loadRegistersAndSchemas()
Expand Down Expand Up @@ -176,7 +193,7 @@ export default {
const newSchema = {
title: `Extended ${currentItem.title}`,
description: `Schema extending ${currentItem.title}`,
extend: currentItem.id,
allOf: [currentItem.id],
properties: {},
required: [],
}
Expand Down
28 changes: 25 additions & 3 deletions src/sidebars/search/SearchSideBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,7 @@ export default {
return {
id: schema.id,
title: schema.title || schema.name || `Schema ${schema.id}`,
properties: schema.properties,
properties: this.resolveInheritedProperties(schema),
}
})
.filter(Boolean) // Remove null entries
Expand Down Expand Up @@ -996,6 +996,27 @@ export default {
this.updateRouteQueryFromState()
},

/**
* Merge inherited properties from allOf parent schemas into the given schema's own properties.
* Own properties take precedence over inherited ones.
* @param {object} schema - Schema object with optional allOf and properties
* @return {object} Merged properties object
*/
resolveInheritedProperties(schema) {
const allOf = schema?.allOf || []
const inherited = {}
for (const ref of allOf) {
const schemaId = typeof ref === 'object' ? ref.id : ref
const parentSchema = schemaStore.schemaList.find(s =>
s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId),
)
if (parentSchema?.properties) {
Object.assign(inherited, parentSchema.properties)
}
}
return { ...inherited, ...(schema?.properties || {}) }
},

/** Load facet options for type 'search' via _facets=extend; facet data then comes from objectStore.searchFacets. */
async discoverFacets() {
if (!objectStore.searchParams.register || !objectStore.searchParams.schema) return
Expand All @@ -1004,8 +1025,9 @@ export default {
this.facetableFields = null
await objectStore.refetchSearchCollection({ _facets: 'extend', _limit: 0 })
this.facetData = objectStore.searchFacets || null
this.facetableFields = objectStore.searchSchema?.properties
? { object_fields: objectStore.searchSchema.properties }
const searchSchema = objectStore.searchSchema
this.facetableFields = searchSchema?.properties
? { object_fields: this.resolveInheritedProperties(searchSchema) }
: {}
} catch (error) {
console.error('Error loading complete faceting data:', error)
Expand Down
6 changes: 6 additions & 0 deletions src/store/modules/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,12 @@ export const useSchemaStore = defineStore('schema', {
delete cleaned.archive
delete cleaned.version // Backend determines version

// New schemas have id: '' — omit it entirely so the backend unambiguously
// treats the request as a create rather than an update with an empty id.
if (!cleaned.id) {
delete cleaned.id
}

// Keep configuration object intact - backend should handle it
// Ensure configuration object exists with default values if not present
if (!cleaned.configuration) {
Expand Down
17 changes: 16 additions & 1 deletion src/views/search/SearchIndex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,23 @@ export default {
normalizedSchema() {
const schema = objectStore.searchSchema
if (!schema || !schema.properties) return schema
// Merge inherited properties from allOf parent schemas so extended schemas
// expose the full property set (own + inherited) for columns and form fields.
const allOf = schema.allOf || []
const inheritedProperties = {}
for (const ref of allOf) {
const schemaId = typeof ref === 'object' ? ref.id : ref
const parentSchema = schemaStore.schemaList.find(s =>
s.id === schemaId || s.uuid === schemaId || String(s.id) === String(schemaId),
)
if (parentSchema?.properties) {
Object.assign(inheritedProperties, parentSchema.properties)
}
}
// Own properties take precedence over inherited; normalize order values
const rawProperties = { ...inheritedProperties, ...schema.properties }
const properties = {}
for (const [key, prop] of Object.entries(schema.properties)) {
for (const [key, prop] of Object.entries(rawProperties)) {
properties[key] = prop.order !== undefined
? { ...prop, order: Number(prop.order) }
: prop
Expand Down
Loading