@@ -237,6 +237,127 @@ func ApplySchemaChangesOverExisting(
237237 }, nil
238238}
239239
240+ // ApplySchemaChangesDryRun performs all the validation that ApplySchemaChanges would do,
241+ // but without actually writing any changes to the datastore.
242+ func ApplySchemaChangesDryRun (ctx context.Context , reader datastore.Reader , caveatTypeSet * caveattypes.TypeSet , validated * ValidatedSchemaChanges ) (* AppliedSchemaChanges , error ) {
243+ existingCaveats , err := reader .ListAllCaveats (ctx )
244+ if err != nil {
245+ return nil , err
246+ }
247+
248+ existingObjectDefs , err := reader .ListAllNamespaces (ctx )
249+ if err != nil {
250+ return nil , err
251+ }
252+
253+ return ApplySchemaChangesOverExistingDryRun (ctx , reader , caveatTypeSet , validated , datastore .DefinitionsOf (existingCaveats ), datastore .DefinitionsOf (existingObjectDefs ))
254+ }
255+
256+ // ApplySchemaChangesOverExistingDryRun performs dry-run validation of schema changes against
257+ // existing caveat and object definitions, without actually writing any changes.
258+ func ApplySchemaChangesOverExistingDryRun (
259+ ctx context.Context ,
260+ reader datastore.Reader ,
261+ caveatTypeSet * caveattypes.TypeSet ,
262+ validated * ValidatedSchemaChanges ,
263+ existingCaveats []* core.CaveatDefinition ,
264+ existingObjectDefs []* core.NamespaceDefinition ,
265+ ) (* AppliedSchemaChanges , error ) {
266+ // Build a map of existing caveats to determine those being removed, if any.
267+ existingCaveatDefMap := make (map [string ]* core.CaveatDefinition , len (existingCaveats ))
268+ existingCaveatDefNames := mapz .NewSet [string ]()
269+
270+ for _ , existingCaveat := range existingCaveats {
271+ existingCaveatDefMap [existingCaveat .Name ] = existingCaveat
272+ existingCaveatDefNames .Insert (existingCaveat .Name )
273+ }
274+
275+ // For each caveat definition, perform a diff and ensure the changes will not result in type errors.
276+ caveatDefsWithChanges := make ([]* core.CaveatDefinition , 0 , len (validated .compiled .CaveatDefinitions ))
277+ for _ , caveatDef := range validated .compiled .CaveatDefinitions {
278+ diff , err := sanityCheckCaveatChanges (ctx , nil , caveatTypeSet , caveatDef , existingCaveatDefMap )
279+ if err != nil {
280+ return nil , err
281+ }
282+
283+ if len (diff .Deltas ()) > 0 {
284+ caveatDefsWithChanges = append (caveatDefsWithChanges , caveatDef )
285+ }
286+ }
287+
288+ removedCaveatDefNames := existingCaveatDefNames .Subtract (validated .newCaveatDefNames )
289+
290+ // Build a map of existing definitions to determine those being removed, if any.
291+ existingObjectDefMap := make (map [string ]* core.NamespaceDefinition , len (existingObjectDefs ))
292+ existingObjectDefNames := mapz .NewSet [string ]()
293+ for _ , existingDef := range existingObjectDefs {
294+ existingObjectDefMap [existingDef .Name ] = existingDef
295+ existingObjectDefNames .Insert (existingDef .Name )
296+ }
297+
298+ // For each definition, perform a diff and ensure the changes will not result in any
299+ // breaking changes.
300+ objectDefsWithChanges := make ([]* core.NamespaceDefinition , 0 , len (validated .compiled .ObjectDefinitions ))
301+ for _ , nsdef := range validated .compiled .ObjectDefinitions {
302+ diff , err := sanityCheckNamespaceChangesDryRun (ctx , reader , nsdef , existingObjectDefMap )
303+ if err != nil {
304+ return nil , err
305+ }
306+
307+ if len (diff .Deltas ()) > 0 {
308+ objectDefsWithChanges = append (objectDefsWithChanges , nsdef )
309+
310+ vts , ok := validated .validatedTypeSystems [nsdef .Name ]
311+ if ! ok {
312+ return nil , spiceerrors .MustBugf ("validated type system not found for namespace `%s`" , nsdef .Name )
313+ }
314+
315+ if err := namespace .AnnotateNamespace (vts ); err != nil {
316+ return nil , err
317+ }
318+ }
319+ }
320+
321+ log .Ctx (ctx ).
322+ Trace ().
323+ Int ("objectDefinitions" , len (validated .compiled .ObjectDefinitions )).
324+ Int ("caveatDefinitions" , len (validated .compiled .CaveatDefinitions )).
325+ Int ("objectDefsWithChanges" , len (objectDefsWithChanges )).
326+ Int ("caveatDefsWithChanges" , len (caveatDefsWithChanges )).
327+ Bool ("dryRun" , true ).
328+ Msg ("validated namespace definitions (dry run)" )
329+
330+ // Ensure that deleting namespaces will not result in any relationships left without associated
331+ // schema. We only check the resource type as the subject type is handled by the schema validator,
332+ // which will allow the deletion of the subject type if it is not used in any relation anyway.
333+ removedObjectDefNames := existingObjectDefNames .Subtract (validated .newObjectDefNames )
334+ if ! validated .additiveOnly {
335+ if err := removedObjectDefNames .ForEach (func (nsdefName string ) error {
336+ return ensureNoRelationshipsExistWithResourceTypeDryRun (ctx , reader , nsdefName )
337+ }); err != nil {
338+ return nil , err
339+ }
340+ }
341+
342+ log .Ctx (ctx ).Trace ().
343+ Interface ("objectDefinitions" , validated .compiled .ObjectDefinitions ).
344+ Interface ("caveatDefinitions" , validated .compiled .CaveatDefinitions ).
345+ Object ("addedOrChangedObjectDefinitions" , validated .newObjectDefNames ).
346+ Object ("removedObjectDefinitions" , removedObjectDefNames ).
347+ Object ("addedOrChangedCaveatDefinitions" , validated .newCaveatDefNames ).
348+ Object ("removedCaveatDefinitions" , removedCaveatDefNames ).
349+ Bool ("dryRun" , true ).
350+ Msg ("completed schema update validation (dry run)" )
351+
352+ return & AppliedSchemaChanges {
353+ TotalOperationCount : len (validated .compiled .ObjectDefinitions ) + len (validated .compiled .CaveatDefinitions ) + removedObjectDefNames .Len () + removedCaveatDefNames .Len (),
354+ NewObjectDefNames : validated .newObjectDefNames .Subtract (existingObjectDefNames ).AsSlice (),
355+ RemovedObjectDefNames : removedObjectDefNames .AsSlice (),
356+ NewCaveatDefNames : validated .newCaveatDefNames .Subtract (existingCaveatDefNames ).AsSlice (),
357+ RemovedCaveatDefNames : removedCaveatDefNames .AsSlice (),
358+ }, nil
359+ }
360+
240361// sanityCheckCaveatChanges ensures that a caveat definition being written does not break
241362// the types of the parameters that may already exist on relationships.
242363func sanityCheckCaveatChanges (
@@ -284,6 +405,24 @@ func ensureNoRelationshipsExistWithResourceType(ctx context.Context, rwt datasto
284405 )
285406}
286407
408+ // ensureNoRelationshipsExistWithResourceTypeDryRun ensures that no relationships exist within the namespace
409+ // with the given name as a resource type, using only read operations.
410+ func ensureNoRelationshipsExistWithResourceTypeDryRun (ctx context.Context , reader datastore.Reader , namespaceName string ) error {
411+ qy , qyErr := reader .QueryRelationships (
412+ ctx ,
413+ datastore.RelationshipsFilter {OptionalResourceType : namespaceName },
414+ options .WithLimit (options .LimitOne ),
415+ options .WithQueryShape (queryshape .FindResourceOfType ),
416+ )
417+ return errorIfTupleIteratorReturnsTuples (
418+ ctx ,
419+ qy ,
420+ qyErr ,
421+ "cannot delete object definition `%s`, as a relationship exists under it" ,
422+ namespaceName ,
423+ )
424+ }
425+
287426// sanityCheckNamespaceChanges ensures that a namespace definition being written does not result
288427// in breaking changes, such as relationships without associated defined schema object definitions
289428// and relations.
@@ -451,3 +590,153 @@ func errorIfTupleIteratorReturnsTuples(_ context.Context, qy datastore.Relations
451590
452591 return nil
453592}
593+
594+ // sanityCheckNamespaceChangesDryRun performs the same validation as sanityCheckNamespaceChanges
595+ // but using only read operations from a datastore.Reader.
596+ func sanityCheckNamespaceChangesDryRun (
597+ ctx context.Context ,
598+ reader datastore.Reader ,
599+ nsdef * core.NamespaceDefinition ,
600+ existingDefs map [string ]* core.NamespaceDefinition ,
601+ ) (* nsdiff.Diff , error ) {
602+ // Ensure that the updated namespace does not break the existing tuple data.
603+ existing := existingDefs [nsdef .Name ]
604+ diff , err := nsdiff .DiffNamespaces (existing , nsdef )
605+ if err != nil {
606+ return nil , err
607+ }
608+
609+ for _ , delta := range diff .Deltas () {
610+ switch delta .Type {
611+ case nsdiff .RemovedRelation :
612+ // NOTE: We add the subject filters here to ensure the reverse relationship index is used
613+ // by the datastores. As there is no index that has {namespace, relation} directly, but there
614+ // *is* an index that has {subject_namespace, subject_relation, namespace, relation}, we can
615+ // force the datastore to use the reverse index by adding the subject filters.
616+ var previousRelation * core.Relation
617+ for _ , relation := range existing .Relation {
618+ if relation .Name == delta .RelationName {
619+ previousRelation = relation
620+ break
621+ }
622+ }
623+
624+ if previousRelation == nil {
625+ return nil , spiceerrors .MustBugf ("relation `%s` not found in existing namespace definition" , delta .RelationName )
626+ }
627+
628+ subjectSelectors := make ([]datastore.SubjectsSelector , 0 , len (previousRelation .TypeInformation .AllowedDirectRelations ))
629+ for _ , allowedType := range previousRelation .TypeInformation .AllowedDirectRelations {
630+ if allowedType .GetRelation () == datastore .Ellipsis {
631+ subjectSelectors = append (subjectSelectors , datastore.SubjectsSelector {
632+ OptionalSubjectType : allowedType .Namespace ,
633+ RelationFilter : datastore.SubjectRelationFilter {
634+ IncludeEllipsisRelation : true ,
635+ },
636+ })
637+ } else {
638+ subjectSelectors = append (subjectSelectors , datastore.SubjectsSelector {
639+ OptionalSubjectType : allowedType .Namespace ,
640+ RelationFilter : datastore.SubjectRelationFilter {
641+ NonEllipsisRelation : allowedType .GetRelation (),
642+ },
643+ })
644+ }
645+ }
646+
647+ qy , qyErr := reader .QueryRelationships (
648+ ctx ,
649+ datastore.RelationshipsFilter {
650+ OptionalResourceType : nsdef .Name ,
651+ OptionalResourceRelation : delta .RelationName ,
652+ OptionalSubjectsSelectors : subjectSelectors ,
653+ },
654+ options .WithLimit (options .LimitOne ),
655+ options .WithQueryShape (queryshape .FindResourceOfTypeAndRelation ),
656+ )
657+
658+ err = errorIfTupleIteratorReturnsTuples (
659+ ctx ,
660+ qy ,
661+ qyErr ,
662+ "cannot delete relation `%s` in object definition `%s`, as a relationship exists under it" , delta .RelationName , nsdef .Name )
663+ if err != nil {
664+ return diff , err
665+ }
666+
667+ // Also check for right sides of tuples.
668+ qy , qyErr = reader .ReverseQueryRelationships (
669+ ctx ,
670+ datastore.SubjectsFilter {
671+ SubjectType : nsdef .Name ,
672+ RelationFilter : datastore.SubjectRelationFilter {
673+ NonEllipsisRelation : delta .RelationName ,
674+ },
675+ },
676+ options .WithLimitForReverse (options .LimitOne ),
677+ options .WithQueryShapeForReverse (queryshape .FindSubjectOfTypeAndRelation ),
678+ )
679+ err = errorIfTupleIteratorReturnsTuples (
680+ ctx ,
681+ qy ,
682+ qyErr ,
683+ "cannot delete relation `%s` in object definition `%s`, as a relationship references it" , delta .RelationName , nsdef .Name )
684+ if err != nil {
685+ return diff , err
686+ }
687+
688+ case nsdiff .RelationAllowedTypeRemoved :
689+ var optionalSubjectIds []string
690+ var relationFilter datastore.SubjectRelationFilter
691+ var optionalCaveatNameFilter datastore.CaveatNameFilter
692+
693+ if delta .AllowedType .GetPublicWildcard () != nil {
694+ optionalSubjectIds = []string {tuple .PublicWildcard }
695+ } else {
696+ relationFilter = datastore.SubjectRelationFilter {
697+ NonEllipsisRelation : delta .AllowedType .GetRelation (),
698+ }
699+ }
700+
701+ if delta .AllowedType .GetRequiredCaveat () != nil && delta .AllowedType .GetRequiredCaveat ().CaveatName != "" {
702+ optionalCaveatNameFilter = datastore .WithCaveatName (delta .AllowedType .GetRequiredCaveat ().CaveatName )
703+ } else {
704+ optionalCaveatNameFilter = datastore .WithNoCaveat ()
705+ }
706+
707+ expirationOption := datastore .ExpirationFilterOptionNoExpiration
708+ if delta .AllowedType .RequiredExpiration != nil {
709+ expirationOption = datastore .ExpirationFilterOptionHasExpiration
710+ }
711+
712+ qyr , qyrErr := reader .QueryRelationships (
713+ ctx ,
714+ datastore.RelationshipsFilter {
715+ OptionalResourceType : nsdef .Name ,
716+ OptionalResourceRelation : delta .RelationName ,
717+ OptionalSubjectsSelectors : []datastore.SubjectsSelector {
718+ {
719+ OptionalSubjectType : delta .AllowedType .Namespace ,
720+ OptionalSubjectIds : optionalSubjectIds ,
721+ RelationFilter : relationFilter ,
722+ },
723+ },
724+ OptionalCaveatNameFilter : optionalCaveatNameFilter ,
725+ OptionalExpirationOption : expirationOption ,
726+ },
727+ options .WithLimit (options .LimitOne ),
728+ options .WithQueryShape (queryshape .FindResourceRelationForSubjectRelation ),
729+ )
730+ err = errorIfTupleIteratorReturnsTuples (
731+ ctx ,
732+ qyr ,
733+ qyrErr ,
734+ "cannot remove allowed type `%s` from relation `%s` in object definition `%s`, as a relationship exists with it" ,
735+ schema .SourceForAllowedRelation (delta .AllowedType ), delta .RelationName , nsdef .Name )
736+ if err != nil {
737+ return diff , err
738+ }
739+ }
740+ }
741+ return diff , nil
742+ }
0 commit comments