@@ -90,8 +90,26 @@ export function validateDocument(response) {
9090 }
9191 }
9292
93- // Step 5: Validate optional included array
94- if ( Object . prototype . hasOwnProperty . call ( response , 'included' ) ) {
93+ // Step 5: Validate optional included array and compound document rules
94+ const hasIncluded = Object . prototype . hasOwnProperty . call ( response , 'included' )
95+
96+ if ( hasIncluded ) {
97+ // First validate that included is only present when data is present
98+ if ( ! hasData ) {
99+ results . valid = false
100+ results . errors . push ( {
101+ test : 'Compound Document Structure' ,
102+ message : 'Included member must not be present without data member'
103+ } )
104+ } else {
105+ results . details . push ( {
106+ test : 'Compound Document Structure' ,
107+ status : 'passed' ,
108+ message : 'Included member is properly paired with data member'
109+ } )
110+ }
111+
112+ // Validate the included member structure
95113 const includedValidation = validateIncludedMember ( response . included )
96114 results . details . push ( ...includedValidation . details )
97115 if ( ! includedValidation . valid ) {
@@ -101,6 +119,19 @@ export function validateDocument(response) {
101119 if ( includedValidation . warnings ) {
102120 results . warnings . push ( ...includedValidation . warnings )
103121 }
122+
123+ // Validate compound document if both data and included are present and valid
124+ if ( hasData && includedValidation . valid && results . valid ) {
125+ const compoundValidation = validateCompoundDocument ( response . data , response . included )
126+ results . details . push ( ...compoundValidation . details )
127+ if ( ! compoundValidation . valid ) {
128+ results . valid = false
129+ results . errors . push ( ...compoundValidation . errors )
130+ }
131+ if ( compoundValidation . warnings ) {
132+ results . warnings . push ( ...compoundValidation . warnings )
133+ }
134+ }
104135 }
105136
106137 // Step 6: Validate optional links object
@@ -256,6 +287,78 @@ function validateIncludedMember(included) {
256287 return results
257288}
258289
290+ /**
291+ * Validates compound document rules - linkage, duplicates, and circular references
292+ * @param {any } data - The primary data (resource object, array of resources, or null)
293+ * @param {Array } included - The included resources array
294+ * @returns {Object } Validation result
295+ */
296+ function validateCompoundDocument ( data , included ) {
297+ const results = {
298+ valid : true ,
299+ errors : [ ] ,
300+ warnings : [ ] ,
301+ details : [ ]
302+ }
303+
304+ // Skip most validation if data is null, but check for orphaned included resources
305+ if ( data === null ) {
306+ if ( included . length > 0 ) {
307+ results . valid = false
308+ results . errors . push ( {
309+ test : 'Resource Linkage' ,
310+ message : `All ${ included . length } included resources are orphaned when data is null`
311+ } )
312+ } else {
313+ results . details . push ( {
314+ test : 'Compound Document Validation' ,
315+ status : 'passed' ,
316+ message : 'No compound document validation needed (data is null and included is empty)'
317+ } )
318+ }
319+ return results
320+ }
321+
322+ // Skip validation if included is empty
323+ if ( ! Array . isArray ( included ) || included . length === 0 ) {
324+ results . details . push ( {
325+ test : 'Compound Document Validation' ,
326+ status : 'passed' ,
327+ message : 'No compound document validation needed (included is empty)'
328+ } )
329+ return results
330+ }
331+
332+ // Step 1: Check for duplicate resources in included array
333+ const duplicateValidation = validateNoDuplicatesInIncluded ( included )
334+ results . details . push ( ...duplicateValidation . details )
335+ if ( ! duplicateValidation . valid ) {
336+ results . valid = false
337+ results . errors . push ( ...duplicateValidation . errors )
338+ }
339+
340+ // Step 2: Validate that all included resources are referenced from primary data
341+ const linkageValidation = validateResourceLinkage ( data , included )
342+ results . details . push ( ...linkageValidation . details )
343+ if ( ! linkageValidation . valid ) {
344+ results . valid = false
345+ results . errors . push ( ...linkageValidation . errors )
346+ }
347+ if ( linkageValidation . warnings ) {
348+ results . warnings . push ( ...linkageValidation . warnings )
349+ }
350+
351+ // Step 3: Check for circular references
352+ const circularRefValidation = validateNoCircularReferences ( data , included )
353+ results . details . push ( ...circularRefValidation . details )
354+ if ( ! circularRefValidation . valid ) {
355+ results . valid = false
356+ results . errors . push ( ...circularRefValidation . errors )
357+ }
358+
359+ return results
360+ }
361+
259362/**
260363 * Validates the links member
261364 * @param {any } links - The links value to validate
@@ -503,4 +606,251 @@ function validateJsonApiMember(jsonapi) {
503606 }
504607
505608 return results
609+ }
610+
611+ /**
612+ * Validates that there are no duplicate resources in the included array
613+ * @param {Array } included - The included resources array
614+ * @returns {Object } Validation result
615+ */
616+ function validateNoDuplicatesInIncluded ( included ) {
617+ const results = {
618+ valid : true ,
619+ errors : [ ] ,
620+ details : [ ]
621+ }
622+
623+ const seenResources = new Set ( )
624+ const duplicates = [ ]
625+
626+ for ( let i = 0 ; i < included . length ; i ++ ) {
627+ const resource = included [ i ]
628+ if ( resource && typeof resource === 'object' && resource . type && resource . id ) {
629+ const resourceKey = `${ resource . type } :${ resource . id } `
630+ if ( seenResources . has ( resourceKey ) ) {
631+ duplicates . push ( { type : resource . type , id : resource . id , index : i } )
632+ } else {
633+ seenResources . add ( resourceKey )
634+ }
635+ }
636+ }
637+
638+ if ( duplicates . length > 0 ) {
639+ results . valid = false
640+ duplicates . forEach ( dup => {
641+ results . errors . push ( {
642+ test : 'Included Resource Duplicates' ,
643+ message : `Duplicate resource found in included array: ${ dup . type } :${ dup . id } at index ${ dup . index } `
644+ } )
645+ } )
646+ } else {
647+ results . details . push ( {
648+ test : 'Included Resource Duplicates' ,
649+ status : 'passed' ,
650+ message : `No duplicate resources found in included array (${ included . length } unique resources)`
651+ } )
652+ }
653+
654+ return results
655+ }
656+
657+ /**
658+ * Validates that all included resources are referenced from the primary data
659+ * @param {any } data - The primary data (resource object, array, or null)
660+ * @param {Array } included - The included resources array
661+ * @returns {Object } Validation result
662+ */
663+ function validateResourceLinkage ( data , included ) {
664+ const results = {
665+ valid : true ,
666+ errors : [ ] ,
667+ warnings : [ ] ,
668+ details : [ ]
669+ }
670+
671+ // Get all resource identifiers referenced from primary data
672+ const referencedResources = extractReferencedResources ( data )
673+
674+ // Create set of included resource identifiers
675+ const includedResources = new Set ( )
676+ included . forEach ( resource => {
677+ if ( resource && typeof resource === 'object' && resource . type && resource . id ) {
678+ includedResources . add ( `${ resource . type } :${ resource . id } ` )
679+ }
680+ } )
681+
682+ // Find orphaned resources (included but not referenced)
683+ const orphanedResources = [ ]
684+ includedResources . forEach ( resourceKey => {
685+ if ( ! referencedResources . has ( resourceKey ) ) {
686+ const [ type , id ] = resourceKey . split ( ':' )
687+ orphanedResources . push ( { type, id } )
688+ }
689+ } )
690+
691+ // Find missing resources (referenced but not included)
692+ const missingResources = [ ]
693+ referencedResources . forEach ( resourceKey => {
694+ if ( ! includedResources . has ( resourceKey ) ) {
695+ const [ type , id ] = resourceKey . split ( ':' )
696+ missingResources . push ( { type, id } )
697+ }
698+ } )
699+
700+ // Report orphaned resources as errors
701+ if ( orphanedResources . length > 0 ) {
702+ results . valid = false
703+ orphanedResources . forEach ( resource => {
704+ results . errors . push ( {
705+ test : 'Resource Linkage' ,
706+ message : `Orphaned included resource: ${ resource . type } :${ resource . id } is not referenced from primary data`
707+ } )
708+ } )
709+ }
710+
711+ // Report missing resources as warnings (they might be intentionally omitted)
712+ if ( missingResources . length > 0 ) {
713+ missingResources . forEach ( resource => {
714+ results . warnings . push ( {
715+ test : 'Resource Linkage' ,
716+ message : `Referenced resource ${ resource . type } :${ resource . id } is not included in compound document`
717+ } )
718+ } )
719+ }
720+
721+ if ( orphanedResources . length === 0 && missingResources . length === 0 ) {
722+ results . details . push ( {
723+ test : 'Resource Linkage' ,
724+ status : 'passed' ,
725+ message : `Perfect linkage: all ${ included . length } included resources are referenced from primary data`
726+ } )
727+ } else if ( orphanedResources . length === 0 ) {
728+ results . details . push ( {
729+ test : 'Resource Linkage' ,
730+ status : 'passed' ,
731+ message : `No orphaned resources found (${ missingResources . length } referenced resources not included)`
732+ } )
733+ }
734+
735+ return results
736+ }
737+
738+ /**
739+ * Extracts all resource identifiers referenced from relationships in primary data
740+ * @param {any } data - The primary data (resource object, array, or null)
741+ * @returns {Set } Set of resource identifiers in format "type:id"
742+ */
743+ function extractReferencedResources ( data ) {
744+ const references = new Set ( )
745+
746+ if ( data === null ) {
747+ return references
748+ }
749+
750+ const resources = Array . isArray ( data ) ? data : [ data ]
751+
752+ resources . forEach ( resource => {
753+ if ( resource && typeof resource === 'object' && resource . relationships ) {
754+ Object . values ( resource . relationships ) . forEach ( relationship => {
755+ if ( relationship && relationship . data ) {
756+ const relData = Array . isArray ( relationship . data ) ? relationship . data : [ relationship . data ]
757+ relData . forEach ( rel => {
758+ if ( rel && rel . type && rel . id ) {
759+ references . add ( `${ rel . type } :${ rel . id } ` )
760+ }
761+ } )
762+ }
763+ } )
764+ }
765+ } )
766+
767+ return references
768+ }
769+
770+ /**
771+ * Validates that there are no circular references in compound documents
772+ * Note: In JSON:API, bidirectional relationships are normal and allowed.
773+ * This validation primarily serves as informational analysis rather than strict validation.
774+ * @param {any } data - The primary data
775+ * @param {Array } included - The included resources array
776+ * @returns {Object } Validation result
777+ */
778+ function validateNoCircularReferences ( data , included ) {
779+ const results = {
780+ valid : true ,
781+ errors : [ ] ,
782+ details : [ ]
783+ }
784+
785+ // Create a map of all resources (primary + included) for reference lookup
786+ const allResources = new Map ( )
787+
788+ // Add primary data resources
789+ if ( data !== null ) {
790+ const primaryResources = Array . isArray ( data ) ? data : [ data ]
791+ primaryResources . forEach ( resource => {
792+ if ( resource && typeof resource === 'object' && resource . type && resource . id ) {
793+ allResources . set ( `${ resource . type } :${ resource . id } ` , resource )
794+ }
795+ } )
796+ }
797+
798+ // Add included resources
799+ included . forEach ( resource => {
800+ if ( resource && typeof resource === 'object' && resource . type && resource . id ) {
801+ allResources . set ( `${ resource . type } :${ resource . id } ` , resource )
802+ }
803+ } )
804+
805+ // Analyze relationship structure for informational purposes
806+ const relationshipCount = analyzeRelationshipStructure ( allResources )
807+
808+ results . details . push ( {
809+ test : 'Circular References' ,
810+ status : 'passed' ,
811+ message : `Relationship structure analyzed: ${ allResources . size } resources with ${ relationshipCount . total } relationships (${ relationshipCount . bidirectional } bidirectional). Bidirectional relationships are normal in JSON:API.`
812+ } )
813+
814+ return results
815+ }
816+
817+ /**
818+ * Analyzes relationship structure in compound documents for informational purposes
819+ * @param {Map } allResources - Map of all resources (primary + included)
820+ * @returns {Object } Analysis results with counts
821+ */
822+ function analyzeRelationshipStructure ( allResources ) {
823+ let totalRelationships = 0
824+ let bidirectionalCount = 0
825+ const relationships = new Set ( )
826+
827+ for ( const [ resourceKey , resource ] of allResources ) {
828+ if ( resource . relationships ) {
829+ Object . values ( resource . relationships ) . forEach ( relationship => {
830+ if ( relationship && relationship . data ) {
831+ const relData = Array . isArray ( relationship . data ) ? relationship . data : [ relationship . data ]
832+ relData . forEach ( rel => {
833+ if ( rel && rel . type && rel . id ) {
834+ const relKey = `${ rel . type } :${ rel . id } `
835+ const relationshipPair = `${ resourceKey } ->${ relKey } `
836+ const reverseRelationshipPair = `${ relKey } ->${ resourceKey } `
837+
838+ relationships . add ( relationshipPair )
839+ totalRelationships ++
840+
841+ // Check if reverse relationship exists
842+ if ( relationships . has ( reverseRelationshipPair ) ) {
843+ bidirectionalCount ++
844+ }
845+ }
846+ } )
847+ }
848+ } )
849+ }
850+ }
851+
852+ return {
853+ total : totalRelationships ,
854+ bidirectional : Math . floor ( bidirectionalCount / 2 ) // Each bidirectional pair is counted twice
855+ }
506856}
0 commit comments