From 5f97d95d18d928494017f44b4329c81ea5f28fd5 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 23 Oct 2026 10:43:32 +0530 Subject: [PATCH 01/11] Add graphschema package with KindRef parsing and formatting functions --- opengin/core-api/pkg/graphschema/types.go | 71 +++++++++++++++ .../core-api/pkg/graphschema/types_test.go | 87 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 opengin/core-api/pkg/graphschema/types.go create mode 100644 opengin/core-api/pkg/graphschema/types_test.go diff --git a/opengin/core-api/pkg/graphschema/types.go b/opengin/core-api/pkg/graphschema/types.go new file mode 100644 index 00000000..a5d23139 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/types.go @@ -0,0 +1,71 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +// Package graphschema defines the user-supplied schema that describes the shape of the +// entity graph: which kinds (Major/Minor) exist and which relationships may connect them. +// +// The same types are used for the document users write (YAML or JSON) and for the form +// stored in MongoDB, so there is no conversion step between the two. +package graphschema + +import ( + "fmt" + "strings" +) + +// KindSeparator separates the Major and Minor parts of a kind reference, e.g. "region/country". +const KindSeparator = "/" + +// AnyMinor is the wildcard Minor kind. In KindDef.Minors it allows any minor kind under the +// Major, and in a kind reference ("Organization/*") it matches every minor kind of that Major. +const AnyMinor = "*" + +// Schema is the complete graph schema for a deployment. +type Schema struct { + Description string `yaml:"description,omitempty" json:"description,omitempty" bson:"description,omitempty"` + KindDefs []KindDef `yaml:"kindDefs" json:"kindDefs" bson:"kindDefs"` + RelationshipDefs []RelationshipDef `yaml:"relationshipDefs" json:"relationshipDefs" bson:"relationshipDefs"` +} + +// KindDef declares a Major kind and the Minor kinds allowed under it. +type KindDef struct { + Major string `yaml:"major" json:"major" bson:"major"` + Minors []string `yaml:"minors" json:"minors" bson:"minors"` +} + +// RelationshipDef declares a relationship name and every (from, to) kind pair it may connect. +// A name is declared once; additional valid pairs are added to its Endpoints. +type RelationshipDef struct { + Name string `yaml:"name" json:"name" bson:"name"` + Endpoints []Endpoint `yaml:"endpoints" json:"endpoints" bson:"endpoints"` +} + +// Endpoint is one allowed (from, to) pair for a relationship. Both sides are kind references +// in "Major/Minor" form. From is the entity that holds the relationship and To is its +// relatedEntityId, matching the direction edges are created in Neo4j. +type Endpoint struct { + From string `yaml:"from" json:"from" bson:"from"` + To string `yaml:"to" json:"to" bson:"to"` +} + +// ParseKindRef splits a kind reference such as "region/country-level-1+lk-province" into its +// Major and Minor parts. It splits on the first separator, so only the Major part is guaranteed +// to be free of "/". Both parts must be non-empty. +func ParseKindRef(ref string) (major string, minor string, err error) { + major, minor, found := strings.Cut(ref, KindSeparator) + if !found { + return "", "", fmt.Errorf("invalid kind reference %q: expected Major%sMinor", ref, KindSeparator) + } + if major == "" { + return "", "", fmt.Errorf("invalid kind reference %q: Major is empty", ref) + } + if minor == "" { + return "", "", fmt.Errorf("invalid kind reference %q: Minor is empty", ref) + } + return major, minor, nil +} + +// FormatKindRef joins a Major and Minor kind into a kind reference, e.g. "region/country". +func FormatKindRef(major string, minor string) string { + return major + KindSeparator + minor +} diff --git a/opengin/core-api/pkg/graphschema/types_test.go b/opengin/core-api/pkg/graphschema/types_test.go new file mode 100644 index 00000000..ff4e669a --- /dev/null +++ b/opengin/core-api/pkg/graphschema/types_test.go @@ -0,0 +1,87 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseKindRef(t *testing.T) { + tests := map[string]struct { + input string + expectedMajor string + expectedMinor string + expectError bool + }{ + "simple kind": { + input: "Organization/Department", + expectedMajor: "Organization", + expectedMinor: "Department", + }, + "minor with plus and dashes": { + input: "region/country-level-1+lk-province", + expectedMajor: "region", + expectedMinor: "country-level-1+lk-province", + }, + "wildcard minor": { + input: "Person/*", + expectedMajor: "Person", + expectedMinor: AnyMinor, + }, + "splits on first separator only": { + input: "region/a/b", + expectedMajor: "region", + expectedMinor: "a/b", + }, + "missing separator": { + input: "region", + expectError: true, + }, + "empty major": { + input: "/country", + expectError: true, + }, + "empty minor": { + input: "region/", + expectError: true, + }, + "empty string": { + input: "", + expectError: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + major, minor, err := ParseKindRef(tc.input) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedMajor, major) + assert.Equal(t, tc.expectedMinor, minor) + }) + } +} + +func TestFormatKindRef(t *testing.T) { + assert.Equal(t, "region/country", FormatKindRef("region", "country")) + assert.Equal(t, "Person/*", FormatKindRef("Person", AnyMinor)) +} + +func TestKindRefRoundTrip(t *testing.T) { + refs := []string{ + "region/country", + "region/country-level-3+lk-divisional-secretariat-division", + "Organization/Minister", + } + for _, ref := range refs { + major, minor, err := ParseKindRef(ref) + assert.NoError(t, err) + assert.Equal(t, ref, FormatKindRef(major, minor)) + } +} From 8f773664dbb35a94a365c5f6681a7c5ad6bf09d4 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 23 Oct 2026 10:53:10 +0530 Subject: [PATCH 02/11] Add JSON and YAML test data for Sri Lanka regions and central government schema --- .../testdata/invalid/cardinality_field.yaml | 10 ++ .../testdata/invalid/category_field.yaml | 9 ++ .../invalid/duplicate_endpoint_pair.yaml | 9 ++ .../testdata/invalid/duplicate_major.yaml | 10 ++ .../testdata/invalid/duplicate_minor.yaml | 8 ++ .../invalid/duplicate_relationship_name.yaml | 14 +++ .../testdata/invalid/empty_endpoints.yaml | 7 ++ .../testdata/invalid/empty_minor.yaml | 8 ++ .../testdata/invalid/invalid_major_name.yaml | 8 ++ .../invalid/invalid_relationship_name.yaml | 8 ++ .../invalid/malformed_kind_reference.yaml | 8 ++ .../testdata/invalid/minor_with_slash.yaml | 8 ++ .../testdata/invalid/missing_major.yaml | 9 ++ .../testdata/invalid/missing_minors.yaml | 9 ++ .../invalid/missing_relationship_name.yaml | 7 ++ .../testdata/invalid/reserved_major.yaml | 10 ++ .../invalid/reserved_relationship_name.yaml | 11 ++ .../invalid/unknown_kind_reference.yaml | 8 ++ .../invalid/unknown_top_level_key.yaml | 9 ++ .../invalid/wildcard_unknown_major.yaml | 9 ++ .../testdata/lk_regions_government.json | 102 ++++++++++++++++++ .../testdata/lk_regions_government.yaml | 61 +++++++++++ .../testdata/tree_with_loader_keys.yaml | 51 +++++++++ .../graphschema/testdata/valid_wildcards.yaml | 11 ++ 24 files changed, 404 insertions(+) create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/lk_regions_government.json create mode 100644 opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml new file mode 100644 index 00000000..b0514391 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml @@ -0,0 +1,10 @@ +# Expected: parse error, unknown key "cardinality". Cardinality is not part of the v1 schema. +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - from: region/country + to: region/province + cardinality: SINGLE diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml new file mode 100644 index 00000000..1a567d92 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml @@ -0,0 +1,9 @@ +# Expected: parse error, unknown key "category". Category is not part of the v1 schema. +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + category: COMPOSITION + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml new file mode 100644 index 00000000..9d802039 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml @@ -0,0 +1,9 @@ +# Expected violation: relationshipDefs[0].endpoints[1] (duplicate pair region/country -> region/province). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml new file mode 100644 index 00000000..9c86131f --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml @@ -0,0 +1,10 @@ +# Expected violation: kindDefs[1].major (duplicate Major "region"; add minors to kindDefs[0]). +kindDefs: + - major: region + minors: [country] + - major: region + minors: [province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml new file mode 100644 index 00000000..32bacdc2 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml @@ -0,0 +1,8 @@ +# Expected violation: kindDefs[0].minors[2] (duplicate minor kind "country"). +kindDefs: + - major: region + minors: [country, province, country] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml new file mode 100644 index 00000000..79db2d54 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml @@ -0,0 +1,14 @@ +# Expected violation: relationshipDefs[1].name (duplicate "located_in"; add the pair to +# relationshipDefs[0].endpoints instead). +kindDefs: + - major: region + minors: [country, district] + - major: Organization + minors: [Government, Department] +relationshipDefs: + - name: located_in + endpoints: + - { from: Organization/Department, to: region/district } + - name: located_in + endpoints: + - { from: Organization/Government, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml new file mode 100644 index 00000000..767c47fd --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml @@ -0,0 +1,7 @@ +# Expected violation: relationshipDefs[0].endpoints (required, non-empty). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: [] diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml new file mode 100644 index 00000000..22f6369d --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml @@ -0,0 +1,8 @@ +# Expected violation: kindDefs[0].minors[1] (empty minor kind). +kindDefs: + - major: region + minors: [country, ""] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml new file mode 100644 index 00000000..f05d98dc --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml @@ -0,0 +1,8 @@ +# Expected violation: kindDefs[0].major ("lk-region" is not a safe Neo4j label). +kindDefs: + - major: lk-region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: lk-region/country, to: lk-region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml new file mode 100644 index 00000000..cbda0298 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml @@ -0,0 +1,8 @@ +# Expected violation: relationshipDefs[0].name ("lk-province" is not a safe Neo4j relationship type). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: lk-province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml new file mode 100644 index 00000000..0503a0fa --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml @@ -0,0 +1,8 @@ +# Expected violation: relationshipDefs[0].endpoints[0].to ("region" is not in Major/Minor form). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml new file mode 100644 index 00000000..d404de4d --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml @@ -0,0 +1,8 @@ +# Expected violation: kindDefs[0].minors[1] (minor kind contains "/"). +kindDefs: + - major: region + minors: [country, province/east] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province/east } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml new file mode 100644 index 00000000..465e01a9 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml @@ -0,0 +1,9 @@ +# Expected violation: kindDefs[1].major (required). +kindDefs: + - major: region + minors: [country, province] + - minors: [Department] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml new file mode 100644 index 00000000..a3a75e6c --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml @@ -0,0 +1,9 @@ +# Expected violation: kindDefs[1].minors (required, non-empty). +kindDefs: + - major: region + minors: [country, province] + - major: Organization +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml new file mode 100644 index 00000000..f1186c78 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml @@ -0,0 +1,7 @@ +# Expected violation: relationshipDefs[0].name (required). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml new file mode 100644 index 00000000..20ee73f1 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml @@ -0,0 +1,10 @@ +# Expected violation: kindDefs[1].major ("Dataset" is reserved for system attribute nodes). +kindDefs: + - major: region + minors: [country, province] + - major: Dataset + minors: [tabular] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml new file mode 100644 index 00000000..29e53a95 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml @@ -0,0 +1,11 @@ +# Expected violation: relationshipDefs[1].name ("IS_ATTRIBUTE" is reserved for system attribute edges). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } + - name: IS_ATTRIBUTE + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml new file mode 100644 index 00000000..a8def480 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml @@ -0,0 +1,8 @@ +# Expected violation: relationshipDefs[0].endpoints[0].to (unknown kind region/district). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/district } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml new file mode 100644 index 00000000..93d8cab2 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml @@ -0,0 +1,9 @@ +# Expected: parse error, unknown key "namespace". +namespace: lk +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: has_province + endpoints: + - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml new file mode 100644 index 00000000..26a84c3f --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml @@ -0,0 +1,9 @@ +# Expected violation: relationshipDefs[0].endpoints[0].from (Organization/* used but Major +# "Organization" is not declared). +kindDefs: + - major: region + minors: [country, province] +relationshipDefs: + - name: located_in + endpoints: + - { from: Organization/*, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.json b/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.json new file mode 100644 index 00000000..8b605e44 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.json @@ -0,0 +1,102 @@ +{ + "description": "Sri Lanka regions and central government", + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "country-level-1+lk-province", + "country-level-2+lk-administrative-district", + "country-level-3+lk-divisional-secretariat-division", + "country-level-4+lk-grama-niladhari-division", + "country-level-3+lk-local-government", + "country-level-3+lk-medical-officer-of-health", + "country-level-2+lk-electoral-district", + "country-level-3+lk-electoral-polling-division" + ] + }, + { + "major": "Organization", + "minors": ["Government", "Minister", "Department"] + }, + { + "major": "Person", + "minors": ["Citizen"] + } + ], + "relationshipDefs": [ + { + "name": "province", + "endpoints": [ + { "from": "region/country", "to": "region/country-level-1+lk-province" } + ] + }, + { + "name": "administrative_district", + "endpoints": [ + { "from": "region/country-level-1+lk-province", "to": "region/country-level-2+lk-administrative-district" } + ] + }, + { + "name": "divisional_secretariat_division", + "endpoints": [ + { "from": "region/country-level-2+lk-administrative-district", "to": "region/country-level-3+lk-divisional-secretariat-division" } + ] + }, + { + "name": "grama_niladhari_division", + "endpoints": [ + { "from": "region/country-level-3+lk-divisional-secretariat-division", "to": "region/country-level-4+lk-grama-niladhari-division" } + ] + }, + { + "name": "local_government", + "endpoints": [ + { "from": "region/country-level-2+lk-administrative-district", "to": "region/country-level-3+lk-local-government" } + ] + }, + { + "name": "medical_officer_of_health", + "endpoints": [ + { "from": "region/country-level-2+lk-administrative-district", "to": "region/country-level-3+lk-medical-officer-of-health" } + ] + }, + { + "name": "electoral_district", + "endpoints": [ + { "from": "region/country-level-1+lk-province", "to": "region/country-level-2+lk-electoral-district" } + ] + }, + { + "name": "electoral_polling_division", + "endpoints": [ + { "from": "region/country-level-2+lk-electoral-district", "to": "region/country-level-3+lk-electoral-polling-division" } + ] + }, + { + "name": "has_minister", + "endpoints": [ + { "from": "Organization/Government", "to": "Organization/Minister" } + ] + }, + { + "name": "has_department", + "endpoints": [ + { "from": "Organization/Minister", "to": "Organization/Department" } + ] + }, + { + "name": "appointed_minister", + "endpoints": [ + { "from": "Person/Citizen", "to": "Organization/Minister" } + ] + }, + { + "name": "located_in", + "endpoints": [ + { "from": "Organization/Department", "to": "region/country-level-2+lk-administrative-district" }, + { "from": "Organization/Government", "to": "region/country" } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml b/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml new file mode 100644 index 00000000..3537600f --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml @@ -0,0 +1,61 @@ +description: Sri Lanka regions and central government +kindDefs: + - major: region + minors: + - country + - country-level-1+lk-province + - country-level-2+lk-administrative-district + - country-level-3+lk-divisional-secretariat-division + - country-level-4+lk-grama-niladhari-division + - country-level-3+lk-local-government + - country-level-3+lk-medical-officer-of-health + - country-level-2+lk-electoral-district + - country-level-3+lk-electoral-polling-division + - major: Organization + minors: [Government, Minister, Department] + - major: Person + minors: [Citizen] + +relationshipDefs: + # --- region hierarchy (parent -> child) --- + - name: province + endpoints: + - { from: region/country, to: region/country-level-1+lk-province } + - name: administrative_district + endpoints: + - { from: region/country-level-1+lk-province, to: region/country-level-2+lk-administrative-district } + - name: divisional_secretariat_division + endpoints: + - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-divisional-secretariat-division } + - name: grama_niladhari_division + endpoints: + - { from: region/country-level-3+lk-divisional-secretariat-division, to: region/country-level-4+lk-grama-niladhari-division } + - name: local_government + endpoints: + - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-local-government } + - name: medical_officer_of_health + endpoints: + - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-medical-officer-of-health } + - name: electoral_district + endpoints: + - { from: region/country-level-1+lk-province, to: region/country-level-2+lk-electoral-district } + - name: electoral_polling_division + endpoints: + - { from: region/country-level-2+lk-electoral-district, to: region/country-level-3+lk-electoral-polling-division } + + # --- government structure --- + - name: has_minister + endpoints: + - { from: Organization/Government, to: Organization/Minister } + - name: has_department + endpoints: + - { from: Organization/Minister, to: Organization/Department } + - name: appointed_minister + endpoints: + - { from: Person/Citizen, to: Organization/Minister } + + # --- one name, several (from, to) pairs --- + - name: located_in + endpoints: + - { from: Organization/Department, to: region/country-level-2+lk-administrative-district } + - { from: Organization/Government, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml b/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml new file mode 100644 index 00000000..9670b05a --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml @@ -0,0 +1,51 @@ +# A hierarchical loader-style document, which is NOT the graph schema format. +# Parsing must reject it: the top-level keys (minor, file, geojson, children, ...) are unknown. +major: region +minor: country +file: country.csv +geojson: geojson/country.geojson +children: + - minor: country-level-1+lk-province + file: province.csv + geojson: geojson/province.geojson + parent_column: country_id + relation: province + children: + - minor: country-level-2+lk-administrative-district + file: district.csv + geojson: geojson/district.geojson + parent_column: province_id + relation: administrative_district + children: + - minor: country-level-3+lk-divisional-secretariat-division + file: dsd.csv + geojson: geojson/dsd.geojson + parent_column: district_id + relation: divisional_secretariat_division + children: + - minor: country-level-4+lk-grama-niladhari-division + file: gnd.csv + geojson: geojson/gnd.geojson + parent_column: dsd_id + relation: grama_niladhari_division + - minor: country-level-3+lk-local-government + file: lg.csv + geojson: geojson/lg.geojson + parent_column: district_id + relation: local_government + - minor: country-level-3+lk-medical-officer-of-health + file: moh.csv + geojson: geojson/moh.geojson + parent_column: district_id + relation: medical_officer_of_health + - minor: country-level-2+lk-electoral-district + file: ed.csv + geojson: geojson/ed.geojson + parent_column: province_id + relation: electoral_district + children: + - minor: country-level-3+lk-electoral-polling-division + file: pd.csv + geojson: geojson/pd.geojson + parent_column: ed_id + relation: electoral_polling_division diff --git a/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml b/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml new file mode 100644 index 00000000..de374cc4 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml @@ -0,0 +1,11 @@ +# Valid: "*" as a minor kind allows any minor under that Major, and "Major/*" in an +# endpoint matches every minor kind of a declared Major. +kindDefs: + - major: Organization + minors: [Minister, Department] + - major: Person + minors: ["*"] +relationshipDefs: + - name: works_at + endpoints: + - { from: Person/*, to: Organization/* } From ec03dcd99a60754b9dedb2de36aeb16c3f7fba8b Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 23 Oct 2026 11:08:12 +0530 Subject: [PATCH 03/11] Refactor graphschema types to remove YAML tags and only use json --- .../pkg/graphschema/testdata/README.md | 37 ++++++++++ .../testdata/invalid/cardinality_field.json | 23 ++++++ .../testdata/invalid/cardinality_field.yaml | 10 --- .../testdata/invalid/category_field.json | 23 ++++++ .../testdata/invalid/category_field.yaml | 9 --- .../invalid/duplicate_endpoint_pair.json | 26 +++++++ .../invalid/duplicate_endpoint_pair.yaml | 9 --- .../testdata/invalid/duplicate_major.json | 27 +++++++ .../testdata/invalid/duplicate_major.yaml | 10 --- .../testdata/invalid/duplicate_minor.json | 23 ++++++ .../testdata/invalid/duplicate_minor.yaml | 8 --- .../invalid/duplicate_relationship_name.json | 38 ++++++++++ .../invalid/duplicate_relationship_name.yaml | 14 ---- .../testdata/invalid/empty_endpoints.json | 17 +++++ .../testdata/invalid/empty_endpoints.yaml | 7 -- .../testdata/invalid/empty_minor.json | 22 ++++++ .../testdata/invalid/empty_minor.yaml | 8 --- .../testdata/invalid/invalid_major_name.json | 22 ++++++ .../testdata/invalid/invalid_major_name.yaml | 8 --- .../invalid/invalid_relationship_name.json | 22 ++++++ .../invalid/invalid_relationship_name.yaml | 8 --- .../invalid/malformed_kind_reference.json | 22 ++++++ .../invalid/malformed_kind_reference.yaml | 8 --- .../testdata/invalid/minor_with_slash.json | 22 ++++++ .../testdata/invalid/minor_with_slash.yaml | 8 --- .../testdata/invalid/missing_major.json | 27 +++++++ .../testdata/invalid/missing_major.yaml | 9 --- .../testdata/invalid/missing_minors.json | 25 +++++++ .../testdata/invalid/missing_minors.yaml | 9 --- .../invalid/missing_relationship_name.json | 21 ++++++ .../invalid/missing_relationship_name.yaml | 7 -- .../testdata/invalid/reserved_major.json | 28 ++++++++ .../testdata/invalid/reserved_major.yaml | 10 --- .../invalid/reserved_relationship_name.json | 31 ++++++++ .../invalid/reserved_relationship_name.yaml | 11 --- .../invalid/unknown_kind_reference.json | 22 ++++++ .../invalid/unknown_kind_reference.yaml | 8 --- .../invalid/unknown_top_level_key.json | 23 ++++++ .../invalid/unknown_top_level_key.yaml | 9 --- .../invalid/wildcard_unknown_major.json | 22 ++++++ .../invalid/wildcard_unknown_major.yaml | 9 --- .../testdata/lk_regions_government.yaml | 61 ---------------- .../testdata/tree_with_loader_keys.json | 72 +++++++++++++++++++ .../testdata/tree_with_loader_keys.yaml | 51 ------------- .../graphschema/testdata/valid_wildcards.json | 28 ++++++++ .../graphschema/testdata/valid_wildcards.yaml | 11 --- opengin/core-api/pkg/graphschema/types.go | 20 +++--- 47 files changed, 633 insertions(+), 312 deletions(-) create mode 100644 opengin/core-api/pkg/graphschema/testdata/README.md create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/category_field.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml delete mode 100644 opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml create mode 100644 opengin/core-api/pkg/graphschema/testdata/valid_wildcards.json delete mode 100644 opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml diff --git a/opengin/core-api/pkg/graphschema/testdata/README.md b/opengin/core-api/pkg/graphschema/testdata/README.md new file mode 100644 index 00000000..08cd456b --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/README.md @@ -0,0 +1,37 @@ +# Graph schema test fixtures + +The graph schema is submitted as JSON, so every fixture is JSON. JSON has no comments, +so the purpose of each file is recorded here. + +## Valid + +| File | Purpose | +|---|---| +| `lk_regions_government.json` | Sri Lanka region hierarchy plus government structure: 13 kinds, 12 relationship names, 13 endpoint pairs (`located_in` has two). | +| `valid_wildcards.json` | `"*"` as a minor kind, and `Major/*` kind references in endpoints. | + +## Rejected + +| File | Expected result | +|---|---| +| `tree_with_loader_keys.json` | Parse error: a hierarchical loader-style document (`major`, `minor`, `file`, `geojson`, `children`, ...) is not the graph schema format, so its keys are unknown. | +| `invalid/cardinality_field.json` | Parse error, unknown key "cardinality". Cardinality is not part of the v1 schema. | +| `invalid/category_field.json` | Parse error, unknown key "category". Category is not part of the v1 schema. | +| `invalid/duplicate_endpoint_pair.json` | Violation at `relationshipDefs[0].endpoints[1]`: duplicate pair region/country -> region/province. | +| `invalid/duplicate_major.json` | Violation at `kindDefs[1].major`: duplicate Major "region"; add minors to kindDefs[0]. | +| `invalid/duplicate_minor.json` | Violation at `kindDefs[0].minors[2]`: duplicate minor kind "country". | +| `invalid/duplicate_relationship_name.json` | Violation at `relationshipDefs[1].name`: duplicate "located_in"; add the pair to relationshipDefs[0].endpoints instead. | +| `invalid/empty_endpoints.json` | Violation at `relationshipDefs[0].endpoints`: required, non-empty. | +| `invalid/empty_minor.json` | Violation at `kindDefs[0].minors[1]`: empty minor kind. | +| `invalid/invalid_major_name.json` | Violation at `kindDefs[0].major`: "lk-region" is not a safe Neo4j label. | +| `invalid/invalid_relationship_name.json` | Violation at `relationshipDefs[0].name`: "lk-province" is not a safe Neo4j relationship type. | +| `invalid/malformed_kind_reference.json` | Violation at `relationshipDefs[0].endpoints[0].to`: "region" is not in Major/Minor form. | +| `invalid/minor_with_slash.json` | Violation at `kindDefs[0].minors[1]`: minor kind contains "/". | +| `invalid/missing_major.json` | Violation at `kindDefs[1].major`: required. | +| `invalid/missing_minors.json` | Violation at `kindDefs[1].minors`: required, non-empty. | +| `invalid/missing_relationship_name.json` | Violation at `relationshipDefs[0].name`: required. | +| `invalid/reserved_major.json` | Violation at `kindDefs[1].major`: "Dataset" is reserved for system attribute nodes. | +| `invalid/reserved_relationship_name.json` | Violation at `relationshipDefs[1].name`: "IS_ATTRIBUTE" is reserved for system attribute edges. | +| `invalid/unknown_kind_reference.json` | Violation at `relationshipDefs[0].endpoints[0].to`: unknown kind region/district. | +| `invalid/unknown_top_level_key.json` | Parse error, unknown key "namespace". | +| `invalid/wildcard_unknown_major.json` | Violation at `relationshipDefs[0].endpoints[0].from`: Organization/* used but Major "Organization" is not declared. | diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.json b/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.json new file mode 100644 index 00000000..007d713b --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.json @@ -0,0 +1,23 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province", + "cardinality": "SINGLE" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml deleted file mode 100644 index b0514391..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/cardinality_field.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Expected: parse error, unknown key "cardinality". Cardinality is not part of the v1 schema. -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - from: region/country - to: region/province - cardinality: SINGLE diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.json b/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.json new file mode 100644 index 00000000..94c21a98 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.json @@ -0,0 +1,23 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "category": "COMPOSITION", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml deleted file mode 100644 index 1a567d92..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/category_field.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected: parse error, unknown key "category". Category is not part of the v1 schema. -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - category: COMPOSITION - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.json b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.json new file mode 100644 index 00000000..bdc3e51c --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.json @@ -0,0 +1,26 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + }, + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml deleted file mode 100644 index 9d802039..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_endpoint_pair.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected violation: relationshipDefs[0].endpoints[1] (duplicate pair region/country -> region/province). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.json b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.json new file mode 100644 index 00000000..cf1e9e33 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.json @@ -0,0 +1,27 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country" + ] + }, + { + "major": "region", + "minors": [ + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml deleted file mode 100644 index 9c86131f..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_major.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Expected violation: kindDefs[1].major (duplicate Major "region"; add minors to kindDefs[0]). -kindDefs: - - major: region - minors: [country] - - major: region - minors: [province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.json b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.json new file mode 100644 index 00000000..0ae0db3e --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.json @@ -0,0 +1,23 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province", + "country" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml deleted file mode 100644 index 32bacdc2..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_minor.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: kindDefs[0].minors[2] (duplicate minor kind "country"). -kindDefs: - - major: region - minors: [country, province, country] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.json b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.json new file mode 100644 index 00000000..c22ffea0 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.json @@ -0,0 +1,38 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "district" + ] + }, + { + "major": "Organization", + "minors": [ + "Government", + "Department" + ] + } + ], + "relationshipDefs": [ + { + "name": "located_in", + "endpoints": [ + { + "from": "Organization/Department", + "to": "region/district" + } + ] + }, + { + "name": "located_in", + "endpoints": [ + { + "from": "Organization/Government", + "to": "region/country" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml deleted file mode 100644 index 79db2d54..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/duplicate_relationship_name.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# Expected violation: relationshipDefs[1].name (duplicate "located_in"; add the pair to -# relationshipDefs[0].endpoints instead). -kindDefs: - - major: region - minors: [country, district] - - major: Organization - minors: [Government, Department] -relationshipDefs: - - name: located_in - endpoints: - - { from: Organization/Department, to: region/district } - - name: located_in - endpoints: - - { from: Organization/Government, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.json b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.json new file mode 100644 index 00000000..77a3d5b9 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.json @@ -0,0 +1,17 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml deleted file mode 100644 index 767c47fd..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_endpoints.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Expected violation: relationshipDefs[0].endpoints (required, non-empty). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: [] diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.json b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.json new file mode 100644 index 00000000..f33c32a3 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/country" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml deleted file mode 100644 index 22f6369d..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_minor.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: kindDefs[0].minors[1] (empty minor kind). -kindDefs: - - major: region - minors: [country, ""] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.json b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.json new file mode 100644 index 00000000..1d60f159 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "lk-region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "lk-region/country", + "to": "lk-region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml deleted file mode 100644 index f05d98dc..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_major_name.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: kindDefs[0].major ("lk-region" is not a safe Neo4j label). -kindDefs: - - major: lk-region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: lk-region/country, to: lk-region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.json b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.json new file mode 100644 index 00000000..a62ccdf8 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "lk-province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml deleted file mode 100644 index cbda0298..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/invalid_relationship_name.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: relationshipDefs[0].name ("lk-province" is not a safe Neo4j relationship type). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: lk-province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.json b/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.json new file mode 100644 index 00000000..c7d31000 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml deleted file mode 100644 index 0503a0fa..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/malformed_kind_reference.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: relationshipDefs[0].endpoints[0].to ("region" is not in Major/Minor form). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.json b/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.json new file mode 100644 index 00000000..a3a1f340 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province/east" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province/east" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml deleted file mode 100644 index d404de4d..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/minor_with_slash.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: kindDefs[0].minors[1] (minor kind contains "/"). -kindDefs: - - major: region - minors: [country, province/east] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province/east } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.json b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.json new file mode 100644 index 00000000..89afbb3e --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.json @@ -0,0 +1,27 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + }, + { + "minors": [ + "Department" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml deleted file mode 100644 index 465e01a9..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_major.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected violation: kindDefs[1].major (required). -kindDefs: - - major: region - minors: [country, province] - - minors: [Department] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.json b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.json new file mode 100644 index 00000000..4b3f0735 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.json @@ -0,0 +1,25 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + }, + { + "major": "Organization" + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml deleted file mode 100644 index a3a75e6c..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_minors.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected violation: kindDefs[1].minors (required, non-empty). -kindDefs: - - major: region - minors: [country, province] - - major: Organization -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.json b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.json new file mode 100644 index 00000000..6624ab21 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.json @@ -0,0 +1,21 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml deleted file mode 100644 index f1186c78..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/missing_relationship_name.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Expected violation: relationshipDefs[0].name (required). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.json b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.json new file mode 100644 index 00000000..da94e722 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.json @@ -0,0 +1,28 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + }, + { + "major": "Dataset", + "minors": [ + "tabular" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml deleted file mode 100644 index 20ee73f1..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_major.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Expected violation: kindDefs[1].major ("Dataset" is reserved for system attribute nodes). -kindDefs: - - major: region - minors: [country, province] - - major: Dataset - minors: [tabular] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.json b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.json new file mode 100644 index 00000000..945af6d8 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.json @@ -0,0 +1,31 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + }, + { + "name": "IS_ATTRIBUTE", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml deleted file mode 100644 index 29e53a95..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/reserved_relationship_name.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Expected violation: relationshipDefs[1].name ("IS_ATTRIBUTE" is reserved for system attribute edges). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } - - name: IS_ATTRIBUTE - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.json b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.json new file mode 100644 index 00000000..eb9d2574 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/district" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml deleted file mode 100644 index a8def480..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_kind_reference.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Expected violation: relationshipDefs[0].endpoints[0].to (unknown kind region/district). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/district } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.json b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.json new file mode 100644 index 00000000..585ac19a --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.json @@ -0,0 +1,23 @@ +{ + "namespace": "lk", + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "has_province", + "endpoints": [ + { + "from": "region/country", + "to": "region/province" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml deleted file mode 100644 index 93d8cab2..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/unknown_top_level_key.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected: parse error, unknown key "namespace". -namespace: lk -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: has_province - endpoints: - - { from: region/country, to: region/province } diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.json b/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.json new file mode 100644 index 00000000..fc01adef --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.json @@ -0,0 +1,22 @@ +{ + "kindDefs": [ + { + "major": "region", + "minors": [ + "country", + "province" + ] + } + ], + "relationshipDefs": [ + { + "name": "located_in", + "endpoints": [ + { + "from": "Organization/*", + "to": "region/country" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml b/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml deleted file mode 100644 index 26a84c3f..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/invalid/wildcard_unknown_major.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# Expected violation: relationshipDefs[0].endpoints[0].from (Organization/* used but Major -# "Organization" is not declared). -kindDefs: - - major: region - minors: [country, province] -relationshipDefs: - - name: located_in - endpoints: - - { from: Organization/*, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml b/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml deleted file mode 100644 index 3537600f..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/lk_regions_government.yaml +++ /dev/null @@ -1,61 +0,0 @@ -description: Sri Lanka regions and central government -kindDefs: - - major: region - minors: - - country - - country-level-1+lk-province - - country-level-2+lk-administrative-district - - country-level-3+lk-divisional-secretariat-division - - country-level-4+lk-grama-niladhari-division - - country-level-3+lk-local-government - - country-level-3+lk-medical-officer-of-health - - country-level-2+lk-electoral-district - - country-level-3+lk-electoral-polling-division - - major: Organization - minors: [Government, Minister, Department] - - major: Person - minors: [Citizen] - -relationshipDefs: - # --- region hierarchy (parent -> child) --- - - name: province - endpoints: - - { from: region/country, to: region/country-level-1+lk-province } - - name: administrative_district - endpoints: - - { from: region/country-level-1+lk-province, to: region/country-level-2+lk-administrative-district } - - name: divisional_secretariat_division - endpoints: - - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-divisional-secretariat-division } - - name: grama_niladhari_division - endpoints: - - { from: region/country-level-3+lk-divisional-secretariat-division, to: region/country-level-4+lk-grama-niladhari-division } - - name: local_government - endpoints: - - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-local-government } - - name: medical_officer_of_health - endpoints: - - { from: region/country-level-2+lk-administrative-district, to: region/country-level-3+lk-medical-officer-of-health } - - name: electoral_district - endpoints: - - { from: region/country-level-1+lk-province, to: region/country-level-2+lk-electoral-district } - - name: electoral_polling_division - endpoints: - - { from: region/country-level-2+lk-electoral-district, to: region/country-level-3+lk-electoral-polling-division } - - # --- government structure --- - - name: has_minister - endpoints: - - { from: Organization/Government, to: Organization/Minister } - - name: has_department - endpoints: - - { from: Organization/Minister, to: Organization/Department } - - name: appointed_minister - endpoints: - - { from: Person/Citizen, to: Organization/Minister } - - # --- one name, several (from, to) pairs --- - - name: located_in - endpoints: - - { from: Organization/Department, to: region/country-level-2+lk-administrative-district } - - { from: Organization/Government, to: region/country } diff --git a/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.json b/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.json new file mode 100644 index 00000000..a8cf80e6 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.json @@ -0,0 +1,72 @@ +{ + "major": "region", + "minor": "country", + "file": "country.csv", + "geojson": "geojson/country.geojson", + "children": [ + { + "minor": "country-level-1+lk-province", + "file": "province.csv", + "geojson": "geojson/province.geojson", + "parent_column": "country_id", + "relation": "province", + "children": [ + { + "minor": "country-level-2+lk-administrative-district", + "file": "district.csv", + "geojson": "geojson/district.geojson", + "parent_column": "province_id", + "relation": "administrative_district", + "children": [ + { + "minor": "country-level-3+lk-divisional-secretariat-division", + "file": "dsd.csv", + "geojson": "geojson/dsd.geojson", + "parent_column": "district_id", + "relation": "divisional_secretariat_division", + "children": [ + { + "minor": "country-level-4+lk-grama-niladhari-division", + "file": "gnd.csv", + "geojson": "geojson/gnd.geojson", + "parent_column": "dsd_id", + "relation": "grama_niladhari_division" + } + ] + }, + { + "minor": "country-level-3+lk-local-government", + "file": "lg.csv", + "geojson": "geojson/lg.geojson", + "parent_column": "district_id", + "relation": "local_government" + }, + { + "minor": "country-level-3+lk-medical-officer-of-health", + "file": "moh.csv", + "geojson": "geojson/moh.geojson", + "parent_column": "district_id", + "relation": "medical_officer_of_health" + } + ] + }, + { + "minor": "country-level-2+lk-electoral-district", + "file": "ed.csv", + "geojson": "geojson/ed.geojson", + "parent_column": "province_id", + "relation": "electoral_district", + "children": [ + { + "minor": "country-level-3+lk-electoral-polling-division", + "file": "pd.csv", + "geojson": "geojson/pd.geojson", + "parent_column": "ed_id", + "relation": "electoral_polling_division" + } + ] + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml b/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml deleted file mode 100644 index 9670b05a..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/tree_with_loader_keys.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# A hierarchical loader-style document, which is NOT the graph schema format. -# Parsing must reject it: the top-level keys (minor, file, geojson, children, ...) are unknown. -major: region -minor: country -file: country.csv -geojson: geojson/country.geojson -children: - - minor: country-level-1+lk-province - file: province.csv - geojson: geojson/province.geojson - parent_column: country_id - relation: province - children: - - minor: country-level-2+lk-administrative-district - file: district.csv - geojson: geojson/district.geojson - parent_column: province_id - relation: administrative_district - children: - - minor: country-level-3+lk-divisional-secretariat-division - file: dsd.csv - geojson: geojson/dsd.geojson - parent_column: district_id - relation: divisional_secretariat_division - children: - - minor: country-level-4+lk-grama-niladhari-division - file: gnd.csv - geojson: geojson/gnd.geojson - parent_column: dsd_id - relation: grama_niladhari_division - - minor: country-level-3+lk-local-government - file: lg.csv - geojson: geojson/lg.geojson - parent_column: district_id - relation: local_government - - minor: country-level-3+lk-medical-officer-of-health - file: moh.csv - geojson: geojson/moh.geojson - parent_column: district_id - relation: medical_officer_of_health - - minor: country-level-2+lk-electoral-district - file: ed.csv - geojson: geojson/ed.geojson - parent_column: province_id - relation: electoral_district - children: - - minor: country-level-3+lk-electoral-polling-division - file: pd.csv - geojson: geojson/pd.geojson - parent_column: ed_id - relation: electoral_polling_division diff --git a/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.json b/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.json new file mode 100644 index 00000000..0ed89e7f --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.json @@ -0,0 +1,28 @@ +{ + "kindDefs": [ + { + "major": "Organization", + "minors": [ + "Minister", + "Department" + ] + }, + { + "major": "Person", + "minors": [ + "*" + ] + } + ], + "relationshipDefs": [ + { + "name": "works_at", + "endpoints": [ + { + "from": "Person/*", + "to": "Organization/*" + } + ] + } + ] +} diff --git a/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml b/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml deleted file mode 100644 index de374cc4..00000000 --- a/opengin/core-api/pkg/graphschema/testdata/valid_wildcards.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Valid: "*" as a minor kind allows any minor under that Major, and "Major/*" in an -# endpoint matches every minor kind of a declared Major. -kindDefs: - - major: Organization - minors: [Minister, Department] - - major: Person - minors: ["*"] -relationshipDefs: - - name: works_at - endpoints: - - { from: Person/*, to: Organization/* } diff --git a/opengin/core-api/pkg/graphschema/types.go b/opengin/core-api/pkg/graphschema/types.go index a5d23139..db0cf68e 100644 --- a/opengin/core-api/pkg/graphschema/types.go +++ b/opengin/core-api/pkg/graphschema/types.go @@ -4,7 +4,7 @@ // Package graphschema defines the user-supplied schema that describes the shape of the // entity graph: which kinds (Major/Minor) exist and which relationships may connect them. // -// The same types are used for the document users write (YAML or JSON) and for the form +// The same types are used for the JSON document users submit and for the form // stored in MongoDB, so there is no conversion step between the two. package graphschema @@ -22,30 +22,30 @@ const AnyMinor = "*" // Schema is the complete graph schema for a deployment. type Schema struct { - Description string `yaml:"description,omitempty" json:"description,omitempty" bson:"description,omitempty"` - KindDefs []KindDef `yaml:"kindDefs" json:"kindDefs" bson:"kindDefs"` - RelationshipDefs []RelationshipDef `yaml:"relationshipDefs" json:"relationshipDefs" bson:"relationshipDefs"` + Description string `json:"description,omitempty" bson:"description,omitempty"` + KindDefs []KindDef `json:"kindDefs" bson:"kindDefs"` + RelationshipDefs []RelationshipDef `json:"relationshipDefs" bson:"relationshipDefs"` } // KindDef declares a Major kind and the Minor kinds allowed under it. type KindDef struct { - Major string `yaml:"major" json:"major" bson:"major"` - Minors []string `yaml:"minors" json:"minors" bson:"minors"` + Major string `json:"major" bson:"major"` + Minors []string `json:"minors" bson:"minors"` } // RelationshipDef declares a relationship name and every (from, to) kind pair it may connect. // A name is declared once; additional valid pairs are added to its Endpoints. type RelationshipDef struct { - Name string `yaml:"name" json:"name" bson:"name"` - Endpoints []Endpoint `yaml:"endpoints" json:"endpoints" bson:"endpoints"` + Name string `json:"name" bson:"name"` + Endpoints []Endpoint `json:"endpoints" bson:"endpoints"` } // Endpoint is one allowed (from, to) pair for a relationship. Both sides are kind references // in "Major/Minor" form. From is the entity that holds the relationship and To is its // relatedEntityId, matching the direction edges are created in Neo4j. type Endpoint struct { - From string `yaml:"from" json:"from" bson:"from"` - To string `yaml:"to" json:"to" bson:"to"` + From string `json:"from" bson:"from"` + To string `json:"to" bson:"to"` } // ParseKindRef splits a kind reference such as "region/country-level-1+lk-province" into its From 91ac5f4bd5c2ee7add8894b7b1040d05248af5d0 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 23 Oct 2026 11:36:27 +0530 Subject: [PATCH 04/11] Add graphschema parsing functionality and comprehensive test suite --- opengin/core-api/pkg/graphschema/parse.go | 38 ++++ .../core-api/pkg/graphschema/parse_test.go | 180 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 opengin/core-api/pkg/graphschema/parse.go create mode 100644 opengin/core-api/pkg/graphschema/parse_test.go diff --git a/opengin/core-api/pkg/graphschema/parse.go b/opengin/core-api/pkg/graphschema/parse.go new file mode 100644 index 00000000..109c7611 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/parse.go @@ -0,0 +1,38 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +// Parse decodes a graph schema document from JSON. +// +// Decoding is strict: unknown fields are rejected (so typos and unsupported keys such as +// "category" or loader fields like "file" are reported rather than silently dropped), and +// anything after the top-level JSON object is an error. Parse only checks that the document +// is well-formed; use Validate to check the schema rules. +func Parse(src []byte) (*Schema, error) { + decoder := json.NewDecoder(bytes.NewReader(src)) + decoder.DisallowUnknownFields() + + var schema Schema + if err := decoder.Decode(&schema); err != nil { + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("invalid graph schema: document is empty") + } + return nil, fmt.Errorf("invalid graph schema: %w", err) + } + + // Only whitespace may follow the schema object. + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("invalid graph schema: unexpected data after the JSON object") + } + + return &schema, nil +} diff --git a/opengin/core-api/pkg/graphschema/parse_test.go b/opengin/core-api/pkg/graphschema/parse_test.go new file mode 100644 index 00000000..1c732b40 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/parse_test.go @@ -0,0 +1,180 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("failed to read fixture %s: %v", name, err) + } + return data +} + +func TestParseRegionsGovernmentFixture(t *testing.T) { + schema, err := Parse(readFixture(t, "lk_regions_government.json")) + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, "Sri Lanka regions and central government", schema.Description) + + // 3 Majors declaring 13 kinds in total + assert.Len(t, schema.KindDefs, 3) + kindCount := 0 + for _, kindDef := range schema.KindDefs { + kindCount += len(kindDef.Minors) + } + assert.Equal(t, 13, kindCount) + assert.Equal(t, KindDef{Major: "Organization", Minors: []string{"Government", "Minister", "Department"}}, schema.KindDefs[1]) + + // 12 relationship names covering 13 endpoint pairs + assert.Len(t, schema.RelationshipDefs, 12) + pairCount := 0 + for _, relationshipDef := range schema.RelationshipDefs { + pairCount += len(relationshipDef.Endpoints) + } + assert.Equal(t, 13, pairCount) + + assert.Equal(t, RelationshipDef{ + Name: "province", + Endpoints: []Endpoint{ + {From: "region/country", To: "region/country-level-1+lk-province"}, + }, + }, schema.RelationshipDefs[0]) + + // one name, several pairs + assert.Equal(t, RelationshipDef{ + Name: "located_in", + Endpoints: []Endpoint{ + {From: "Organization/Department", To: "region/country-level-2+lk-administrative-district"}, + {From: "Organization/Government", To: "region/country"}, + }, + }, schema.RelationshipDefs[11]) +} + +func TestParseWildcardsFixture(t *testing.T) { + schema, err := Parse(readFixture(t, "valid_wildcards.json")) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, []string{AnyMinor}, schema.KindDefs[1].Minors) + assert.Equal(t, Endpoint{From: "Person/*", To: "Organization/*"}, schema.RelationshipDefs[0].Endpoints[0]) +} + +func TestParseRejectsUnknownFields(t *testing.T) { + tests := map[string]struct { + fixture string + unknownField string + }{ + "hierarchical loader document": {fixture: "tree_with_loader_keys.json", unknownField: "major"}, + "unknown top-level key": {fixture: "invalid/unknown_top_level_key.json", unknownField: "namespace"}, + "category on a relationship": {fixture: "invalid/category_field.json", unknownField: "category"}, + "cardinality on an endpoint": {fixture: "invalid/cardinality_field.json", unknownField: "cardinality"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + schema, err := Parse(readFixture(t, tc.fixture)) + assert.Nil(t, schema) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), `unknown field "`+tc.unknownField+`"`) + } + }) + } +} + +// The remaining invalid fixtures break schema rules, not the document format, so they must +// parse successfully and be left for Validate to reject. +func TestParseAcceptsRuleViolationFixtures(t *testing.T) { + parseErrorFixtures := map[string]bool{ + "unknown_top_level_key.json": true, + "category_field.json": true, + "cardinality_field.json": true, + } + + files, err := filepath.Glob(filepath.Join("testdata", "invalid", "*.json")) + if !assert.NoError(t, err) || !assert.NotEmpty(t, files) { + return + } + + for _, file := range files { + name := filepath.Base(file) + if parseErrorFixtures[name] { + continue + } + t.Run(name, func(t *testing.T) { + _, err := Parse(readFixture(t, filepath.Join("invalid", name))) + assert.NoError(t, err) + }) + } +} + +func TestParseMissingFieldsAreLeftEmpty(t *testing.T) { + schema, err := Parse(readFixture(t, "invalid/missing_major.json")) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, "", schema.KindDefs[1].Major) + assert.Equal(t, []string{"Department"}, schema.KindDefs[1].Minors) +} + +func TestParseMalformedInput(t *testing.T) { + tests := map[string]struct { + input string + errorContains string + }{ + "empty input": { + input: "", + errorContains: "document is empty", + }, + "whitespace only": { + input: " \n\t ", + errorContains: "document is empty", + }, + "truncated JSON": { + input: `{"kindDefs": [`, + errorContains: "invalid graph schema", + }, + "top-level array": { + input: `[{"major": "region"}]`, + errorContains: "cannot unmarshal array", + }, + "wrong field type": { + input: `{"kindDefs": [{"major": "region", "minors": "country"}]}`, + errorContains: "cannot unmarshal string", + }, + "second JSON object": { + input: `{"kindDefs": []} {"kindDefs": []}`, + errorContains: "unexpected data after the JSON object", + }, + "trailing garbage": { + input: `{"kindDefs": []} x`, + errorContains: "unexpected data after the JSON object", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + schema, err := Parse([]byte(tc.input)) + assert.Nil(t, schema) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tc.errorContains) + } + }) + } +} + +func TestParseAllowsTrailingWhitespace(t *testing.T) { + _, err := Parse([]byte("{\"kindDefs\": [], \"relationshipDefs\": []}\n\n ")) + assert.NoError(t, err) +} From 634606262edd8c2e046b6ed02c111cfcdaab1026 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 23 Oct 2026 15:18:37 +0530 Subject: [PATCH 05/11] Implement validation logic for graphschema and add comprehensive test suite --- .../core-api/pkg/graphschema/parse_test.go | 6 - .../pkg/graphschema/testdata/README.md | 1 + .../testdata/invalid/empty_kind_defs.json | 4 + opengin/core-api/pkg/graphschema/validate.go | 192 +++++++++++++++++ .../core-api/pkg/graphschema/validate_test.go | 201 ++++++++++++++++++ 5 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 opengin/core-api/pkg/graphschema/testdata/invalid/empty_kind_defs.json create mode 100644 opengin/core-api/pkg/graphschema/validate.go create mode 100644 opengin/core-api/pkg/graphschema/validate_test.go diff --git a/opengin/core-api/pkg/graphschema/parse_test.go b/opengin/core-api/pkg/graphschema/parse_test.go index 1c732b40..f1f85e03 100644 --- a/opengin/core-api/pkg/graphschema/parse_test.go +++ b/opengin/core-api/pkg/graphschema/parse_test.go @@ -96,12 +96,6 @@ func TestParseRejectsUnknownFields(t *testing.T) { // The remaining invalid fixtures break schema rules, not the document format, so they must // parse successfully and be left for Validate to reject. func TestParseAcceptsRuleViolationFixtures(t *testing.T) { - parseErrorFixtures := map[string]bool{ - "unknown_top_level_key.json": true, - "category_field.json": true, - "cardinality_field.json": true, - } - files, err := filepath.Glob(filepath.Join("testdata", "invalid", "*.json")) if !assert.NoError(t, err) || !assert.NotEmpty(t, files) { return diff --git a/opengin/core-api/pkg/graphschema/testdata/README.md b/opengin/core-api/pkg/graphschema/testdata/README.md index 08cd456b..a9bb866f 100644 --- a/opengin/core-api/pkg/graphschema/testdata/README.md +++ b/opengin/core-api/pkg/graphschema/testdata/README.md @@ -21,6 +21,7 @@ so the purpose of each file is recorded here. | `invalid/duplicate_major.json` | Violation at `kindDefs[1].major`: duplicate Major "region"; add minors to kindDefs[0]. | | `invalid/duplicate_minor.json` | Violation at `kindDefs[0].minors[2]`: duplicate minor kind "country". | | `invalid/duplicate_relationship_name.json` | Violation at `relationshipDefs[1].name`: duplicate "located_in"; add the pair to relationshipDefs[0].endpoints instead. | +| `invalid/empty_kind_defs.json` | Violation at `kindDefs`: at least one kind definition is required. | | `invalid/empty_endpoints.json` | Violation at `relationshipDefs[0].endpoints`: required, non-empty. | | `invalid/empty_minor.json` | Violation at `kindDefs[0].minors[1]`: empty minor kind. | | `invalid/invalid_major_name.json` | Violation at `kindDefs[0].major`: "lk-region" is not a safe Neo4j label. | diff --git a/opengin/core-api/pkg/graphschema/testdata/invalid/empty_kind_defs.json b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_kind_defs.json new file mode 100644 index 00000000..295cca85 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/testdata/invalid/empty_kind_defs.json @@ -0,0 +1,4 @@ +{ + "kindDefs": [], + "relationshipDefs": [] +} diff --git a/opengin/core-api/pkg/graphschema/validate.go b/opengin/core-api/pkg/graphschema/validate.go new file mode 100644 index 00000000..fcc581f8 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/validate.go @@ -0,0 +1,192 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "fmt" + "regexp" + "strings" +) + +// ReservedMajor is the Major kind used for system attribute nodes (engine.DatasetType). +// User schemas may not declare it. +const ReservedMajor = "Dataset" + +// ReservedRelationshipName is the relationship used to link entities to their attribute nodes +// (engine.IS_ATTRIBUTE_RELATIONSHIP). User schemas may not declare it. +const ReservedRelationshipName = "IS_ATTRIBUTE" + +// identifierPattern restricts Major kinds and relationship names, which become Neo4j labels and +// relationship types and are concatenated into Cypher queries. +// must start with a letter or _, followed only by letters, digits and _ +var identifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Violation describes one schema rule that a document breaks. Path locates the offending value +// in the document, e.g. "relationshipDefs[11].endpoints[0].to". +type Violation struct { + Path string + Message string +} + +// String formats the violation as "path: message". +func (v Violation) String() string { + if v.Path == "" { + return v.Message + } + return v.Path + ": " + v.Message +} + +// Validate checks a parsed schema against the schema rules and returns every violation found, +// in document order. An empty result means the schema is valid. +func Validate(schema *Schema) []Violation { + if schema == nil { + return []Violation{{Path: "", Message: "schema is required"}} + } + + var violations []Violation + addViolation := func(path string, format string, args ...interface{}) { + violations = append(violations, Violation{Path: path, Message: fmt.Sprintf(format, args...)}) + } + + declaredKinds := validateKindDefs(schema.KindDefs, addViolation) + validateRelationshipDefs(schema.RelationshipDefs, declaredKinds, addViolation) + + return violations +} + +type violationFunc func(path string, format string, args ...interface{}) + +// declaredKinds maps each declared Major kind to the set of its Minor kinds. +type declaredKinds map[string]map[string]bool + +// validateKindDefs checks kindDefs and returns the kinds they declare. Every Major that has a +// name is registered, even one that breaks a rule, and duplicate Majors have their Minor kinds +// merged, so that a single mistake is not reported again by every endpoint that uses the kind. +func validateKindDefs(kindDefs []KindDef, addViolation violationFunc) declaredKinds { + declared := declaredKinds{} + firstDeclaration := map[string]int{} + + if len(kindDefs) == 0 { + addViolation("kindDefs", "at least one kind definition is required") + } + + for i, kindDef := range kindDefs { + majorPath := fmt.Sprintf("kindDefs[%d].major", i) + + switch { + case kindDef.Major == "": + addViolation(majorPath, "major is required") + case !identifierPattern.MatchString(kindDef.Major): + addViolation(majorPath, "%q must match %s", kindDef.Major, identifierPattern.String()) + case kindDef.Major == ReservedMajor: + addViolation(majorPath, "%q is reserved for system attribute nodes", kindDef.Major) + } + + if kindDef.Major != "" { + if first, exists := firstDeclaration[kindDef.Major]; exists { + addViolation(majorPath, "duplicate Major %q; add its minors to kindDefs[%d] instead", kindDef.Major, first) + } else { + firstDeclaration[kindDef.Major] = i + declared[kindDef.Major] = map[string]bool{} + } + } + + if len(kindDef.Minors) == 0 { + addViolation(fmt.Sprintf("kindDefs[%d].minors", i), "at least one minor kind is required") + } + + seenMinors := map[string]bool{} + for j, minor := range kindDef.Minors { + minorPath := fmt.Sprintf("kindDefs[%d].minors[%d]", i, j) + + switch { + case minor == "": + addViolation(minorPath, "minor kind must not be empty") + case strings.Contains(minor, KindSeparator): + addViolation(minorPath, "minor kind %q must not contain %q", minor, KindSeparator) + case seenMinors[minor]: + addViolation(minorPath, "duplicate minor kind %q", minor) + } + + seenMinors[minor] = true + if kindDef.Major != "" { + declared[kindDef.Major][minor] = true + } + } + } + + return declared +} + +// validateRelationshipDefs checks relationshipDefs, resolving every endpoint against the +// declared kinds. +func validateRelationshipDefs(relationshipDefs []RelationshipDef, declared declaredKinds, addViolation violationFunc) { + firstDeclaration := map[string]int{} + + for i, relationshipDef := range relationshipDefs { + namePath := fmt.Sprintf("relationshipDefs[%d].name", i) + + switch { + case relationshipDef.Name == "": + addViolation(namePath, "name is required") + case !identifierPattern.MatchString(relationshipDef.Name): + addViolation(namePath, "%q must match %s", relationshipDef.Name, identifierPattern.String()) + case relationshipDef.Name == ReservedRelationshipName: + addViolation(namePath, "%q is reserved for system attribute relationships", relationshipDef.Name) + } + + if relationshipDef.Name != "" { + if first, exists := firstDeclaration[relationshipDef.Name]; exists { + addViolation(namePath, "duplicate relationship %q; add the pair to relationshipDefs[%d].endpoints instead", + relationshipDef.Name, first) + } else { + firstDeclaration[relationshipDef.Name] = i + } + } + + if len(relationshipDef.Endpoints) == 0 { + addViolation(fmt.Sprintf("relationshipDefs[%d].endpoints", i), "at least one endpoint pair is required") + } + + seenPairs := map[Endpoint]int{} + for j, endpoint := range relationshipDef.Endpoints { + endpointPath := fmt.Sprintf("relationshipDefs[%d].endpoints[%d]", i, j) + + validateKindRef(endpointPath+".from", endpoint.From, declared, addViolation) + validateKindRef(endpointPath+".to", endpoint.To, declared, addViolation) + + if first, exists := seenPairs[endpoint]; exists { + addViolation(endpointPath, "duplicate pair %s -> %s; same as endpoints[%d]", endpoint.From, endpoint.To, first) + } else { + seenPairs[endpoint] = j + } + } + } +} + +// validateKindRef checks that ref is a "Major/Minor" reference to a declared kind. "Major/*" +// matches any minor of a declared Major, and a Major that lists "*" accepts any minor. +func validateKindRef(path string, ref string, declared declaredKinds, addViolation violationFunc) { + if ref == "" { + addViolation(path, "kind is required") + return + } + + major, minor, err := ParseKindRef(ref) + if err != nil { + addViolation(path, "%q is not in Major%sMinor form", ref, KindSeparator) + return + } + + minors, majorDeclared := declared[major] + if !majorDeclared { + addViolation(path, "unknown kind %s: Major %q is not declared", ref, major) + return + } + + if minor == AnyMinor || minors[AnyMinor] || minors[minor] { + return + } + addViolation(path, "unknown kind %s", ref) +} diff --git a/opengin/core-api/pkg/graphschema/validate_test.go b/opengin/core-api/pkg/graphschema/validate_test.go new file mode 100644 index 00000000..7197ce8f --- /dev/null +++ b/opengin/core-api/pkg/graphschema/validate_test.go @@ -0,0 +1,201 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func parseFixture(t *testing.T, name string) *Schema { + t.Helper() + schema, err := Parse(readFixture(t, name)) + if err != nil { + t.Fatalf("failed to parse fixture %s: %v", name, err) + } + return schema +} + +func TestValidateValidFixtures(t *testing.T) { + for _, fixture := range []string{"lk_regions_government.json", "valid_wildcards.json"} { + t.Run(fixture, func(t *testing.T) { + assert.Empty(t, Validate(parseFixture(t, fixture))) + }) + } +} + +// ruleFixtures lists every fixture in testdata/invalid that parses but breaks one schema rule, +// with the single violation it must produce. +var ruleFixtures = map[string]struct { + path string + messageContains string +}{ + // kindDefs + "empty_kind_defs.json": {path: "kindDefs", messageContains: "at least one kind definition is required"}, + "missing_major.json": {path: "kindDefs[1].major", messageContains: "major is required"}, + "invalid_major_name.json": {path: "kindDefs[0].major", messageContains: `"lk-region" must match`}, + "reserved_major.json": {path: "kindDefs[1].major", messageContains: `"Dataset" is reserved`}, + "duplicate_major.json": {path: "kindDefs[1].major", messageContains: `duplicate Major "region"; add its minors to kindDefs[0]`}, + "missing_minors.json": {path: "kindDefs[1].minors", messageContains: "at least one minor kind is required"}, + "empty_minor.json": {path: "kindDefs[0].minors[1]", messageContains: "must not be empty"}, + "minor_with_slash.json": {path: "kindDefs[0].minors[1]", messageContains: `"province/east" must not contain "/"`}, + "duplicate_minor.json": {path: "kindDefs[0].minors[2]", messageContains: `duplicate minor kind "country"`}, + + // relationshipDefs + "missing_relationship_name.json": {path: "relationshipDefs[0].name", messageContains: "name is required"}, + "invalid_relationship_name.json": {path: "relationshipDefs[0].name", messageContains: `"lk-province" must match`}, + "reserved_relationship_name.json": {path: "relationshipDefs[1].name", messageContains: `"IS_ATTRIBUTE" is reserved`}, + "duplicate_relationship_name.json": { + path: "relationshipDefs[1].name", + messageContains: `duplicate relationship "located_in"; add the pair to relationshipDefs[0].endpoints`, + }, + "empty_endpoints.json": {path: "relationshipDefs[0].endpoints", messageContains: "at least one endpoint pair is required"}, + "duplicate_endpoint_pair.json": { + path: "relationshipDefs[0].endpoints[1]", + messageContains: "duplicate pair region/country -> region/province; same as endpoints[0]", + }, + + // kind references + "malformed_kind_reference.json": {path: "relationshipDefs[0].endpoints[0].to", messageContains: `"region" is not in Major/Minor form`}, + "unknown_kind_reference.json": {path: "relationshipDefs[0].endpoints[0].to", messageContains: "unknown kind region/district"}, + "wildcard_unknown_major.json": { + path: "relationshipDefs[0].endpoints[0].from", + messageContains: `unknown kind Organization/*: Major "Organization" is not declared`, + }, +} + +// Fixtures in testdata/invalid that Parse rejects, so they never reach Validate. +var parseErrorFixtures = map[string]bool{ + "unknown_top_level_key.json": true, + "category_field.json": true, + "cardinality_field.json": true, +} + +func TestValidateRuleFixtures(t *testing.T) { + for fixture, expected := range ruleFixtures { + t.Run(fixture, func(t *testing.T) { + violations := Validate(parseFixture(t, filepath.Join("invalid", fixture))) + if assert.Len(t, violations, 1, "expected exactly one violation, got %v", violations) { + assert.Equal(t, expected.path, violations[0].Path) + assert.Contains(t, violations[0].Message, expected.messageContains) + } + }) + } +} + +// Every invalid fixture must be covered by either the parse tests or the rule table above. +func TestValidateRuleFixturesAreComplete(t *testing.T) { + files, err := filepath.Glob(filepath.Join("testdata", "invalid", "*.json")) + if !assert.NoError(t, err) { + return + } + for _, file := range files { + name := filepath.Base(file) + _, inRuleTable := ruleFixtures[name] + assert.True(t, inRuleTable || parseErrorFixtures[name], "fixture %s has no expected result", name) + } +} + +func TestValidateNilSchema(t *testing.T) { + violations := Validate(nil) + if assert.Len(t, violations, 1) { + assert.Equal(t, "schema is required", violations[0].Message) + } +} + +func TestValidateCollectsAllViolationsInOrder(t *testing.T) { + schema := &Schema{ + KindDefs: []KindDef{ + {Major: "region", Minors: []string{"country", "country"}}, + {Major: "Dataset", Minors: []string{"tabular"}}, + }, + RelationshipDefs: []RelationshipDef{ + {Name: "bad-name", Endpoints: []Endpoint{{From: "region/country", To: "region/district"}}}, + {Name: "empty"}, + }, + } + + violations := Validate(schema) + + var paths []string + for _, violation := range violations { + paths = append(paths, violation.Path) + } + assert.Equal(t, []string{ + "kindDefs[0].minors[1]", + "kindDefs[1].major", + "relationshipDefs[0].name", + "relationshipDefs[0].endpoints[0].to", + "relationshipDefs[1].endpoints", + }, paths) +} + +func TestValidateKindReferences(t *testing.T) { + kindDefs := []KindDef{ + {Major: "Organization", Minors: []string{"Minister", "Department"}}, + {Major: "Person", Minors: []string{AnyMinor}}, + } + + tests := map[string]struct { + from string + to string + expectedPath string + expected string + }{ + "declared kinds": { + from: "Organization/Minister", to: "Organization/Department", + }, + "wildcard minor kind accepts any minor": { + from: "Person/Anything", to: "Organization/Minister", + }, + "wildcard reference to a Major with explicit minors": { + from: "Person/Citizen", to: "Organization/*", + }, + "undeclared minor of a declared Major": { + from: "Organization/Ministry", to: "Organization/Department", + expectedPath: "relationshipDefs[0].endpoints[0].from", expected: "unknown kind Organization/Ministry", + }, + "undeclared Major": { + from: "Organization/Minister", to: "Place/City", + expectedPath: "relationshipDefs[0].endpoints[0].to", expected: `Major "Place" is not declared`, + }, + "empty from": { + from: "", to: "Organization/Minister", + expectedPath: "relationshipDefs[0].endpoints[0].from", expected: "kind is required", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + schema := &Schema{ + KindDefs: kindDefs, + RelationshipDefs: []RelationshipDef{ + {Name: "linked_to", Endpoints: []Endpoint{{From: tc.from, To: tc.to}}}, + }, + } + violations := Validate(schema) + if tc.expected == "" { + assert.Empty(t, violations) + return + } + if assert.Len(t, violations, 1) { + assert.Equal(t, tc.expectedPath, violations[0].Path) + assert.Contains(t, violations[0].Message, tc.expected) + } + }) + } +} + +func TestValidateAllowsSchemaWithoutRelationships(t *testing.T) { + schema := &Schema{KindDefs: []KindDef{{Major: "region", Minors: []string{"country"}}}} + assert.Empty(t, Validate(schema)) +} + +func TestViolationString(t *testing.T) { + assert.Equal(t, "kindDefs[0].major: major is required", + Violation{Path: "kindDefs[0].major", Message: "major is required"}.String()) + assert.Equal(t, "schema is required", Violation{Message: "schema is required"}.String()) +} From 21600ade9c2e4cab8c041ec2c122cf5fdf4f6e88 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 12:00:21 +0530 Subject: [PATCH 06/11] Add AllowedPairs method to Schema with corresponding tests --- opengin/core-api/pkg/graphschema/types.go | 15 ++++++ .../core-api/pkg/graphschema/types_test.go | 51 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/opengin/core-api/pkg/graphschema/types.go b/opengin/core-api/pkg/graphschema/types.go index db0cf68e..f6179cbd 100644 --- a/opengin/core-api/pkg/graphschema/types.go +++ b/opengin/core-api/pkg/graphschema/types.go @@ -69,3 +69,18 @@ func ParseKindRef(ref string) (major string, minor string, err error) { func FormatKindRef(major string, minor string) string { return major + KindSeparator + minor } + +// AllowedPairs returns the (from, to) pairs declared for the relationship name, or nil if the +// schema does not declare it. The result is a copy, so callers cannot modify the schema +// through it. +func (s *Schema) AllowedPairs(name string) []Endpoint { + if s == nil { + return nil + } + for _, relationshipDef := range s.RelationshipDefs { + if relationshipDef.Name == name { + return append([]Endpoint(nil), relationshipDef.Endpoints...) + } + } + return nil +} diff --git a/opengin/core-api/pkg/graphschema/types_test.go b/opengin/core-api/pkg/graphschema/types_test.go index ff4e669a..a64e46fe 100644 --- a/opengin/core-api/pkg/graphschema/types_test.go +++ b/opengin/core-api/pkg/graphschema/types_test.go @@ -85,3 +85,54 @@ func TestKindRefRoundTrip(t *testing.T) { assert.Equal(t, ref, FormatKindRef(major, minor)) } } + +func TestAllowedPairs(t *testing.T) { + schema := parseFixture(t, "lk_regions_government.json") + + tests := map[string]struct { + name string + expected []Endpoint + }{ + "single pair": { + name: "province", + expected: []Endpoint{ + {From: "region/country", To: "region/country-level-1+lk-province"}, + }, + }, + "several pairs for one name": { + name: "located_in", + expected: []Endpoint{ + {From: "Organization/Department", To: "region/country-level-2+lk-administrative-district"}, + {From: "Organization/Government", To: "region/country"}, + }, + }, + "undeclared name": { + name: "not_declared", + expected: nil, + }, + "names are case-sensitive": { + name: "Located_In", + expected: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, schema.AllowedPairs(tc.name)) + }) + } +} + +func TestAllowedPairsReturnsCopy(t *testing.T) { + schema := parseFixture(t, "lk_regions_government.json") + + pairs := schema.AllowedPairs("located_in") + pairs[0] = Endpoint{From: "changed", To: "changed"} + + assert.Equal(t, "Organization/Department", schema.AllowedPairs("located_in")[0].From) +} + +func TestAllowedPairsNilSchema(t *testing.T) { + var schema *Schema + assert.Nil(t, schema.AllowedPairs("province")) +} From 2e6d67cea5973e7df3dcbdbc0f4177c315835d48 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 13:50:50 +0530 Subject: [PATCH 07/11] Add patching functionality for graphschema with comprehensive tests --- opengin/core-api/pkg/graphschema/patch.go | 285 ++++++++++++ .../core-api/pkg/graphschema/patch_test.go | 411 ++++++++++++++++++ 2 files changed, 696 insertions(+) create mode 100644 opengin/core-api/pkg/graphschema/patch.go create mode 100644 opengin/core-api/pkg/graphschema/patch_test.go diff --git a/opengin/core-api/pkg/graphschema/patch.go b/opengin/core-api/pkg/graphschema/patch.go new file mode 100644 index 00000000..9667a6db --- /dev/null +++ b/opengin/core-api/pkg/graphschema/patch.go @@ -0,0 +1,285 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "fmt" + "slices" + "sort" +) + +// Patch is an atomic set of changes to a schema: everything in it is applied together, or the +// patch is rejected as a whole. +type Patch struct { + // BaseVersion is the schema version the patch was written against. Apply does not use it; + // the caller checks it against the latest stored version. + BaseVersion int `json:"baseVersion"` + // Description, when set, replaces the schema's description. + Description string `json:"description,omitempty"` + Add PatchAdditions `json:"add"` + Remove PatchRemovals `json:"remove"` +} + +// PatchAdditions lists what a patch adds. A KindDef whose Major already exists adds its Minor +// kinds to that Major, and a RelationshipDef whose name already exists adds its endpoint pairs +// to that relationship. +type PatchAdditions struct { + KindDefs []KindDef `json:"kindDefs,omitempty"` + RelationshipDefs []RelationshipDef `json:"relationshipDefs,omitempty"` +} + +// PatchRemovals lists what a patch removes: kinds as "Major/Minor" references, whole +// relationships by name, and individual endpoint pairs. +type PatchRemovals struct { + Kinds []string `json:"kinds,omitempty"` + Relationships []string `json:"relationships,omitempty"` + Endpoints []EndpointRef `json:"endpoints,omitempty"` +} + +// EndpointRef identifies one endpoint pair of a named relationship. +type EndpointRef struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` +} + +// Apply applies a patch to a copy of base and returns the resulting schema. The base schema is +// never modified. +// +// Removals are applied first (endpoint pairs, then relationships, then kinds), followed by +// additions, and the result is checked with Validate. If anything is wrong, Apply returns a nil +// schema and every violation found: patch violations have paths into the patch (e.g. +// "remove.kinds[0]"), and violations from Validate have paths into the resulting schema. +func Apply(base *Schema, patch *Patch) (*Schema, []Violation) { + if base == nil { + return nil, []Violation{{Path: "", Message: "there is no schema to patch"}} + } + if patch == nil { + return nil, []Violation{{Path: "", Message: "patch is required"}} + } + + p := &patcher{schema: cloneSchema(base), emptiedBy: map[string]int{}} + + p.removeEndpoints(patch.Remove.Endpoints) + p.removeRelationships(patch.Remove.Relationships) + p.removeKinds(patch.Remove.Kinds) + p.addKindDefs(patch.Add.KindDefs) + p.addRelationshipDefs(patch.Add.RelationshipDefs) + p.checkEmptiedRelationships() + + if patch.Description != "" { + p.schema.Description = patch.Description + } + + violations := append(p.violations, Validate(p.schema)...) + if len(violations) > 0 { + return nil, violations + } + return p.schema, nil +} + +// patcher holds the schema being patched and the violations found so far. +type patcher struct { + schema *Schema + violations []Violation + // emptiedBy records, for each relationship whose endpoint pairs were all removed, the index + // of the last remove.endpoints entry that removed one of them. + emptiedBy map[string]int +} + +func (p *patcher) addViolation(path string, format string, args ...interface{}) { + p.violations = append(p.violations, Violation{Path: path, Message: fmt.Sprintf(format, args...)}) +} + +func (p *patcher) findRelationship(name string) int { + for i, relationshipDef := range p.schema.RelationshipDefs { + if relationshipDef.Name == name { + return i + } + } + return -1 +} + +func (p *patcher) findKindDef(major string) int { + for i, kindDef := range p.schema.KindDefs { + if kindDef.Major == major { + return i + } + } + return -1 +} + +func (p *patcher) removeEndpoints(refs []EndpointRef) { + for k, ref := range refs { + path := fmt.Sprintf("remove.endpoints[%d]", k) + + i := p.findRelationship(ref.Name) + if i < 0 { + p.addViolation(path, "unknown relationship %q", ref.Name) + continue + } + + relationshipDef := &p.schema.RelationshipDefs[i] + pair := Endpoint{From: ref.From, To: ref.To} + j := slices.Index(relationshipDef.Endpoints, pair) + if j < 0 { + p.addViolation(path, "relationship %q has no pair %s -> %s", ref.Name, ref.From, ref.To) + continue + } + + relationshipDef.Endpoints = append(relationshipDef.Endpoints[:j], relationshipDef.Endpoints[j+1:]...) + if len(relationshipDef.Endpoints) == 0 { + p.emptiedBy[ref.Name] = k + } + } +} + +func (p *patcher) removeRelationships(names []string) { + for k, name := range names { + i := p.findRelationship(name) + if i < 0 { + p.addViolation(fmt.Sprintf("remove.relationships[%d]", k), "unknown relationship %q", name) + continue + } + p.schema.RelationshipDefs = append(p.schema.RelationshipDefs[:i], p.schema.RelationshipDefs[i+1:]...) + } +} + +// removeKinds removes kinds that no remaining endpoint uses. A kind that is still used is kept, +// so the only violation reported for it is the one that says why. +func (p *patcher) removeKinds(refs []string) { + for k, ref := range refs { + path := fmt.Sprintf("remove.kinds[%d]", k) + + major, minor, err := ParseKindRef(ref) + if err != nil { + p.addViolation(path, "%q is not in Major%sMinor form", ref, KindSeparator) + continue + } + + i := p.findKindDef(major) + if i < 0 || slices.Index(p.schema.KindDefs[i].Minors, minor) < 0 { + p.addViolation(path, "unknown kind %s", ref) + continue + } + + dropsMajor := len(p.schema.KindDefs[i].Minors) == 1 + if user, found := p.findEndpointUsing(ref, major, dropsMajor); found { + p.addViolation(path, "kind %s is still used by %s; remove that pair or relationship in the same patch", ref, user) + continue + } + + kindDef := &p.schema.KindDefs[i] + j := slices.Index(kindDef.Minors, minor) + kindDef.Minors = append(kindDef.Minors[:j], kindDef.Minors[j+1:]...) + if len(kindDef.Minors) == 0 { + p.schema.KindDefs = append(p.schema.KindDefs[:i], p.schema.KindDefs[i+1:]...) + } + } +} + +// findEndpointUsing describes the first endpoint that references ref. When removing ref would +// drop its whole Major, a "Major/*" reference also counts as a use. +func (p *patcher) findEndpointUsing(ref string, major string, dropsMajor bool) (string, bool) { + wildcard := FormatKindRef(major, AnyMinor) + for _, relationshipDef := range p.schema.RelationshipDefs { + for _, endpoint := range relationshipDef.Endpoints { + for _, side := range []string{endpoint.From, endpoint.To} { + if side == ref || (dropsMajor && side == wildcard) { + return fmt.Sprintf("relationship %q pair %s -> %s", relationshipDef.Name, endpoint.From, endpoint.To), true + } + } + } + } + return "", false +} + +func (p *patcher) addKindDefs(kindDefs []KindDef) { + for k, kindDef := range kindDefs { + i := p.findKindDef(kindDef.Major) + if kindDef.Major == "" || i < 0 { + // A new Major (or one with no name, which Validate reports). + p.schema.KindDefs = append(p.schema.KindDefs, KindDef{ + Major: kindDef.Major, + Minors: slices.Clone(kindDef.Minors), + }) + continue + } + + existing := &p.schema.KindDefs[i] + for j, minor := range kindDef.Minors { + if slices.Index(existing.Minors, minor) >= 0 { + p.addViolation(fmt.Sprintf("add.kindDefs[%d].minors[%d]", k, j), + "kind %s is already declared", FormatKindRef(kindDef.Major, minor)) + continue + } + existing.Minors = append(existing.Minors, minor) + } + } +} + +func (p *patcher) addRelationshipDefs(relationshipDefs []RelationshipDef) { + for k, relationshipDef := range relationshipDefs { + i := p.findRelationship(relationshipDef.Name) + if relationshipDef.Name == "" || i < 0 { + // A new relationship (or one with no name, which Validate reports). + p.schema.RelationshipDefs = append(p.schema.RelationshipDefs, RelationshipDef{ + Name: relationshipDef.Name, + Endpoints: slices.Clone(relationshipDef.Endpoints), + }) + continue + } + + existing := &p.schema.RelationshipDefs[i] + for j, endpoint := range relationshipDef.Endpoints { + if slices.Index(existing.Endpoints, endpoint) >= 0 { + p.addViolation(fmt.Sprintf("add.relationshipDefs[%d].endpoints[%d]", k, j), + "relationship %q already has the pair %s -> %s", relationshipDef.Name, endpoint.From, endpoint.To) + continue + } + existing.Endpoints = append(existing.Endpoints, endpoint) + } + } +} + +// checkEmptiedRelationships reports relationships left with no endpoint pairs because the patch +// removed them all, without removing the relationship itself or adding a replacement pair. The +// empty relationship is dropped from the result so Validate does not report it a second time. +func (p *patcher) checkEmptiedRelationships() { + names := make([]string, 0, len(p.emptiedBy)) + for name := range p.emptiedBy { + names = append(names, name) + } + // report in patch order + sort.Slice(names, func(a, b int) bool { return p.emptiedBy[names[a]] < p.emptiedBy[names[b]] }) + + for _, name := range names { + k := p.emptiedBy[name] + i := p.findRelationship(name) + if i < 0 || len(p.schema.RelationshipDefs[i].Endpoints) > 0 { + continue + } + p.addViolation(fmt.Sprintf("remove.endpoints[%d]", k), + "removes the last pair of relationship %q; use remove.relationships instead", name) + p.schema.RelationshipDefs = append(p.schema.RelationshipDefs[:i], p.schema.RelationshipDefs[i+1:]...) + } +} + +// cloneSchema returns a deep copy of s, so patching never changes the caller's schema. +func cloneSchema(s *Schema) *Schema { + clone := &Schema{Description: s.Description} + for _, kindDef := range s.KindDefs { + clone.KindDefs = append(clone.KindDefs, KindDef{ + Major: kindDef.Major, + Minors: slices.Clone(kindDef.Minors), + }) + } + for _, relationshipDef := range s.RelationshipDefs { + clone.RelationshipDefs = append(clone.RelationshipDefs, RelationshipDef{ + Name: relationshipDef.Name, + Endpoints: slices.Clone(relationshipDef.Endpoints), + }) + } + return clone +} diff --git a/opengin/core-api/pkg/graphschema/patch_test.go b/opengin/core-api/pkg/graphschema/patch_test.go new file mode 100644 index 00000000..67661e56 --- /dev/null +++ b/opengin/core-api/pkg/graphschema/patch_test.go @@ -0,0 +1,411 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package graphschema + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Values from testdata/lk_regions_government.json. +const ( + fixtureRelationships = 12 + localGovernmentKind = "region/country-level-3+lk-local-government" + administrativeDistrict = "region/country-level-2+lk-administrative-district" +) + +func findKindDef(schema *Schema, major string) *KindDef { + for i := range schema.KindDefs { + if schema.KindDefs[i].Major == major { + return &schema.KindDefs[i] + } + } + return nil +} + +func findRelationshipDef(schema *Schema, name string) *RelationshipDef { + for i := range schema.RelationshipDefs { + if schema.RelationshipDefs[i].Name == name { + return &schema.RelationshipDefs[i] + } + } + return nil +} + +func TestApplyPatches(t *testing.T) { + tests := map[string]struct { + patch Patch + check func(t *testing.T, result *Schema) + }{ + "empty patch leaves the schema unchanged": { + patch: Patch{}, + check: func(t *testing.T, result *Schema) { + assert.Equal(t, parseFixture(t, "lk_regions_government.json"), result) + }, + }, + "description replaces the schema description": { + patch: Patch{Description: "Updated"}, + check: func(t *testing.T, result *Schema) { + assert.Equal(t, "Updated", result.Description) + }, + }, + "minors are merged into an existing Major": { + patch: Patch{Add: PatchAdditions{KindDefs: []KindDef{ + {Major: "Organization", Minors: []string{"Agency", "Board"}}, + }}}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.KindDefs, 3) + assert.Equal(t, []string{"Government", "Minister", "Department", "Agency", "Board"}, + findKindDef(result, "Organization").Minors) + }, + }, + "a new Major is appended": { + patch: Patch{Add: PatchAdditions{KindDefs: []KindDef{ + {Major: "Place", Minors: []string{"City"}}, + }}}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.KindDefs, 4) + assert.Equal(t, KindDef{Major: "Place", Minors: []string{"City"}}, result.KindDefs[3]) + }, + }, + "pairs are merged into an existing relationship": { + patch: Patch{Add: PatchAdditions{RelationshipDefs: []RelationshipDef{ + {Name: "located_in", Endpoints: []Endpoint{{From: "Organization/Minister", To: "region/country"}}}, + }}}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.RelationshipDefs, fixtureRelationships) + assert.Equal(t, []Endpoint{ + {From: "Organization/Department", To: administrativeDistrict}, + {From: "Organization/Government", To: "region/country"}, + {From: "Organization/Minister", To: "region/country"}, + }, result.AllowedPairs("located_in")) + }, + }, + "a new relationship is appended": { + patch: Patch{Add: PatchAdditions{RelationshipDefs: []RelationshipDef{ + {Name: "reports_to", Endpoints: []Endpoint{{From: "Organization/Department", To: "Organization/Minister"}}}, + }}}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.RelationshipDefs, fixtureRelationships+1) + assert.Equal(t, "reports_to", result.RelationshipDefs[fixtureRelationships].Name) + }, + }, + "one of several pairs is removed": { + patch: Patch{Remove: PatchRemovals{Endpoints: []EndpointRef{ + {Name: "located_in", From: "Organization/Government", To: "region/country"}, + }}}, + check: func(t *testing.T, result *Schema) { + assert.Equal(t, []Endpoint{{From: "Organization/Department", To: administrativeDistrict}}, + result.AllowedPairs("located_in")) + }, + }, + "a relationship and the kind only it used are removed together": { + patch: Patch{Remove: PatchRemovals{ + Kinds: []string{localGovernmentKind}, + Relationships: []string{"local_government"}, + }}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.RelationshipDefs, fixtureRelationships-1) + assert.Nil(t, findRelationshipDef(result, "local_government")) + assert.NotContains(t, findKindDef(result, "region").Minors, "country-level-3+lk-local-government") + }, + }, + "removing a Major's last minor drops the Major": { + patch: Patch{Remove: PatchRemovals{ + Kinds: []string{"Person/Citizen"}, + Relationships: []string{"appointed_minister"}, + }}, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.KindDefs, 2) + assert.Nil(t, findKindDef(result, "Person")) + }, + }, + "the only pair can be replaced in the same patch": { + patch: Patch{ + Remove: PatchRemovals{Endpoints: []EndpointRef{ + {Name: "has_department", From: "Organization/Minister", To: "Organization/Department"}, + }}, + Add: PatchAdditions{RelationshipDefs: []RelationshipDef{ + {Name: "has_department", Endpoints: []Endpoint{{From: "Organization/Government", To: "Organization/Department"}}}, + }}, + }, + check: func(t *testing.T, result *Schema) { + assert.Equal(t, []Endpoint{{From: "Organization/Government", To: "Organization/Department"}}, + result.AllowedPairs("has_department")) + }, + }, + "removing a relationship's pairs and the relationship together": { + patch: Patch{Remove: PatchRemovals{ + Endpoints: []EndpointRef{{Name: "has_department", From: "Organization/Minister", To: "Organization/Department"}}, + Relationships: []string{"has_department"}, + }}, + check: func(t *testing.T, result *Schema) { + assert.Nil(t, findRelationshipDef(result, "has_department")) + }, + }, + "plan example: removals and additions in one patch": { + patch: Patch{ + Description: "Drop local government; add a second province pair", + Add: PatchAdditions{ + KindDefs: []KindDef{{Major: "region", Minors: []string{"country-level-1+lk-western-province-special"}}}, + RelationshipDefs: []RelationshipDef{{Name: "province", Endpoints: []Endpoint{ + {From: "region/country", To: "region/country-level-1+lk-western-province-special"}, + }}}, + }, + Remove: PatchRemovals{ + Kinds: []string{localGovernmentKind}, + Relationships: []string{"local_government"}, + Endpoints: []EndpointRef{{Name: "located_in", From: "Organization/Government", To: "region/country"}}, + }, + }, + check: func(t *testing.T, result *Schema) { + assert.Len(t, result.AllowedPairs("province"), 2) + assert.Len(t, result.AllowedPairs("located_in"), 1) + assert.Nil(t, findRelationshipDef(result, "local_government")) + region := findKindDef(result, "region").Minors + assert.Len(t, region, 9) + assert.Contains(t, region, "country-level-1+lk-western-province-special") + assert.NotContains(t, region, "country-level-3+lk-local-government") + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, violations := Apply(parseFixture(t, "lk_regions_government.json"), &tc.patch) + if assert.Empty(t, violations) && assert.NotNil(t, result) { + tc.check(t, result) + } + }) + } +} + +func TestApplyViolations(t *testing.T) { + tests := map[string]struct { + patch Patch + expected []Violation // Message is matched as a substring + }{ + "removing a pair of an unknown relationship": { + patch: Patch{Remove: PatchRemovals{Endpoints: []EndpointRef{{Name: "missing", From: "a/b", To: "c/d"}}}}, + expected: []Violation{ + {Path: "remove.endpoints[0]", Message: `unknown relationship "missing"`}, + }, + }, + "removing a pair the relationship does not have": { + patch: Patch{Remove: PatchRemovals{Endpoints: []EndpointRef{ + {Name: "located_in", From: "Person/Citizen", To: "region/country"}, + }}}, + expected: []Violation{ + {Path: "remove.endpoints[0]", Message: `relationship "located_in" has no pair Person/Citizen -> region/country`}, + }, + }, + "removing an unknown relationship": { + patch: Patch{Remove: PatchRemovals{Relationships: []string{"missing"}}}, + expected: []Violation{ + {Path: "remove.relationships[0]", Message: `unknown relationship "missing"`}, + }, + }, + "removing an unknown kind": { + patch: Patch{Remove: PatchRemovals{Kinds: []string{"region/district"}}}, + expected: []Violation{ + {Path: "remove.kinds[0]", Message: "unknown kind region/district"}, + }, + }, + "removing a malformed kind reference": { + patch: Patch{Remove: PatchRemovals{Kinds: []string{"region"}}}, + expected: []Violation{ + {Path: "remove.kinds[0]", Message: `"region" is not in Major/Minor form`}, + }, + }, + "removing a kind that is still used": { + patch: Patch{Remove: PatchRemovals{Kinds: []string{"region/country-level-3+lk-medical-officer-of-health"}}}, + expected: []Violation{ + { + Path: "remove.kinds[0]", + Message: `kind region/country-level-3+lk-medical-officer-of-health is still used by relationship ` + + `"medical_officer_of_health"`, + }, + }, + }, + "removing the last pair of a relationship": { + patch: Patch{Remove: PatchRemovals{Endpoints: []EndpointRef{ + {Name: "has_department", From: "Organization/Minister", To: "Organization/Department"}, + }}}, + expected: []Violation{ + {Path: "remove.endpoints[0]", Message: `removes the last pair of relationship "has_department"; use remove.relationships instead`}, + }, + }, + "adding a pair the relationship already has": { + patch: Patch{Add: PatchAdditions{RelationshipDefs: []RelationshipDef{ + {Name: "province", Endpoints: []Endpoint{{From: "region/country", To: "region/country-level-1+lk-province"}}}, + }}}, + expected: []Violation{ + {Path: "add.relationshipDefs[0].endpoints[0]", Message: `relationship "province" already has the pair`}, + }, + }, + "adding a kind that is already declared": { + patch: Patch{Add: PatchAdditions{KindDefs: []KindDef{{Major: "Person", Minors: []string{"Citizen"}}}}}, + expected: []Violation{ + {Path: "add.kindDefs[0].minors[0]", Message: "kind Person/Citizen is already declared"}, + }, + }, + "the result is checked with Validate": { + patch: Patch{Add: PatchAdditions{RelationshipDefs: []RelationshipDef{ + {Name: "located_in_city", Endpoints: []Endpoint{{From: "Organization/Department", To: "Place/City"}}}, + }}}, + expected: []Violation{ + {Path: "relationshipDefs[12].endpoints[0].to", Message: `Major "Place" is not declared`}, + }, + }, + "all violations are reported together": { + patch: Patch{ + Add: PatchAdditions{KindDefs: []KindDef{{Major: "Person", Minors: []string{"Citizen"}}}}, + Remove: PatchRemovals{ + Kinds: []string{"region/district"}, + Relationships: []string{"missing"}, + }, + }, + expected: []Violation{ + {Path: "remove.relationships[0]", Message: `unknown relationship "missing"`}, + {Path: "remove.kinds[0]", Message: "unknown kind region/district"}, + {Path: "add.kindDefs[0].minors[0]", Message: "kind Person/Citizen is already declared"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, violations := Apply(parseFixture(t, "lk_regions_government.json"), &tc.patch) + assert.Nil(t, result) + if assert.Len(t, violations, len(tc.expected), "got %v", violations) { + for i, expected := range tc.expected { + assert.Equal(t, expected.Path, violations[i].Path) + assert.Contains(t, violations[i].Message, expected.Message) + } + } + }) + } +} + +func TestApplyKindRemovalWithWildcardReference(t *testing.T) { + base := &Schema{ + KindDefs: []KindDef{ + {Major: "region", Minors: []string{"country"}}, + {Major: "Organization", Minors: []string{"Minister", "Department"}}, + }, + RelationshipDefs: []RelationshipDef{ + {Name: "located_in", Endpoints: []Endpoint{{From: "Organization/*", To: "region/country"}}}, + }, + } + + // Organization/* still matches Organization/Department, so Minister can go. + result, violations := Apply(base, &Patch{Remove: PatchRemovals{Kinds: []string{"Organization/Minister"}}}) + if assert.Empty(t, violations) { + assert.Equal(t, []string{"Department"}, findKindDef(result, "Organization").Minors) + } + + // Removing both minors would drop the Major that Organization/* refers to. + result, violations = Apply(base, &Patch{Remove: PatchRemovals{ + Kinds: []string{"Organization/Minister", "Organization/Department"}, + }}) + assert.Nil(t, result) + if assert.Len(t, violations, 1, "got %v", violations) { + assert.Equal(t, "remove.kinds[1]", violations[0].Path) + assert.Contains(t, violations[0].Message, + `kind Organization/Department is still used by relationship "located_in" pair Organization/* -> region/country`) + } +} + +func TestApplyNilInputs(t *testing.T) { + result, violations := Apply(nil, &Patch{}) + assert.Nil(t, result) + if assert.Len(t, violations, 1) { + assert.Equal(t, "there is no schema to patch", violations[0].Message) + } + + result, violations = Apply(parseFixture(t, "lk_regions_government.json"), nil) + assert.Nil(t, result) + if assert.Len(t, violations, 1) { + assert.Equal(t, "patch is required", violations[0].Message) + } +} + +func TestApplyDoesNotModifyBase(t *testing.T) { + patches := map[string]Patch{ + "successful patch": { + Description: "changed", + Add: PatchAdditions{ + KindDefs: []KindDef{{Major: "region", Minors: []string{"new-minor"}}}, + RelationshipDefs: []RelationshipDef{{Name: "located_in", Endpoints: []Endpoint{{From: "Organization/Minister", To: "region/country"}}}}, + }, + Remove: PatchRemovals{ + Kinds: []string{localGovernmentKind}, + Relationships: []string{"local_government"}, + Endpoints: []EndpointRef{{Name: "located_in", From: "Organization/Government", To: "region/country"}}, + }, + }, + "rejected patch": { + Remove: PatchRemovals{Endpoints: []EndpointRef{ + {Name: "has_department", From: "Organization/Minister", To: "Organization/Department"}, + }}, + }, + } + + for name, patch := range patches { + t.Run(name, func(t *testing.T) { + base := parseFixture(t, "lk_regions_government.json") + Apply(base, &patch) + assert.Equal(t, parseFixture(t, "lk_regions_government.json"), base) + }) + } +} + +func TestApplyResultDoesNotShareMemoryWithPatch(t *testing.T) { + patch := Patch{Add: PatchAdditions{ + KindDefs: []KindDef{{Major: "Place", Minors: []string{"City"}}}, + RelationshipDefs: []RelationshipDef{ + {Name: "located_in_city", Endpoints: []Endpoint{{From: "Organization/Department", To: "Place/City"}}}, + }, + }} + result, violations := Apply(parseFixture(t, "lk_regions_government.json"), &patch) + if !assert.Empty(t, violations) { + return + } + + patch.Add.KindDefs[0].Minors[0] = "changed" + patch.Add.RelationshipDefs[0].Endpoints[0].To = "changed" + + assert.Equal(t, []string{"City"}, findKindDef(result, "Place").Minors) + assert.Equal(t, "Place/City", result.AllowedPairs("located_in_city")[0].To) +} + +func TestPatchJSON(t *testing.T) { + body := `{ + "baseVersion": 1, + "description": "Drop local government", + "add": { + "kindDefs": [{ "major": "region", "minors": ["country-level-1+lk-western-province-special"] }], + "relationshipDefs": [{ "name": "province", "endpoints": [{ "from": "region/country", "to": "region/country-level-1+lk-western-province-special" }] }] + }, + "remove": { + "kinds": ["region/country-level-3+lk-local-government"], + "relationships": ["local_government"], + "endpoints": [{ "name": "located_in", "from": "Organization/Government", "to": "region/country" }] + } + }` + + var patch Patch + if !assert.NoError(t, json.Unmarshal([]byte(body), &patch)) { + return + } + assert.Equal(t, 1, patch.BaseVersion) + assert.Equal(t, "Drop local government", patch.Description) + assert.Len(t, patch.Add.KindDefs, 1) + assert.Len(t, patch.Add.RelationshipDefs, 1) + assert.Equal(t, []string{localGovernmentKind}, patch.Remove.Kinds) + assert.Equal(t, []string{"local_government"}, patch.Remove.Relationships) + assert.Equal(t, EndpointRef{Name: "located_in", From: "Organization/Government", To: "region/country"}, patch.Remove.Endpoints[0]) +} From 35633b457b3e3ce482dd5d4a443f94de30cf66fd Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 15:01:41 +0530 Subject: [PATCH 08/11] Enhance graphschema parsing to reject null documents and add corresponding test cases --- opengin/core-api/pkg/graphschema/parse.go | 14 ++++++++++---- opengin/core-api/pkg/graphschema/parse_test.go | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/opengin/core-api/pkg/graphschema/parse.go b/opengin/core-api/pkg/graphschema/parse.go index 109c7611..50474262 100644 --- a/opengin/core-api/pkg/graphschema/parse.go +++ b/opengin/core-api/pkg/graphschema/parse.go @@ -15,24 +15,30 @@ import ( // // Decoding is strict: unknown fields are rejected (so typos and unsupported keys such as // "category" or loader fields like "file" are reported rather than silently dropped), and -// anything after the top-level JSON object is an error. Parse only checks that the document -// is well-formed; use Validate to check the schema rules. +// anything after the top-level JSON object is an error. The document must be a JSON object; +// a top-level null is rejected. Parse only checks that the document is well-formed; use +// Validate to check the schema rules. func Parse(src []byte) (*Schema, error) { decoder := json.NewDecoder(bytes.NewReader(src)) decoder.DisallowUnknownFields() - var schema Schema + // Decoding into a pointer leaves it nil for a JSON null, so null can be told apart from an + // object. + var schema *Schema if err := decoder.Decode(&schema); err != nil { if errors.Is(err, io.EOF) { return nil, fmt.Errorf("invalid graph schema: document is empty") } return nil, fmt.Errorf("invalid graph schema: %w", err) } + if schema == nil { + return nil, fmt.Errorf("invalid graph schema: document must be a JSON object, not null") + } // Only whitespace may follow the schema object. if _, err := decoder.Token(); !errors.Is(err, io.EOF) { return nil, fmt.Errorf("invalid graph schema: unexpected data after the JSON object") } - return &schema, nil + return schema, nil } diff --git a/opengin/core-api/pkg/graphschema/parse_test.go b/opengin/core-api/pkg/graphschema/parse_test.go index f1f85e03..4a5e16f8 100644 --- a/opengin/core-api/pkg/graphschema/parse_test.go +++ b/opengin/core-api/pkg/graphschema/parse_test.go @@ -131,6 +131,14 @@ func TestParseMalformedInput(t *testing.T) { input: "", errorContains: "document is empty", }, + "null document": { + input: "null", + errorContains: "document must be a JSON object, not null", + }, + "null document with whitespace": { + input: " null \n", + errorContains: "document must be a JSON object, not null", + }, "whitespace only": { input: " \n\t ", errorContains: "document is empty", From 49e7ffc337ce888816e96c208d978d39c79310c5 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 15:38:49 +0530 Subject: [PATCH 09/11] Add handling for replacing last minor in graphschema patching with new tests --- opengin/core-api/pkg/graphschema/patch.go | 75 +++++++++++++++---- .../core-api/pkg/graphschema/patch_test.go | 68 +++++++++++++++++ 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/opengin/core-api/pkg/graphschema/patch.go b/opengin/core-api/pkg/graphschema/patch.go index 9667a6db..374613df 100644 --- a/opengin/core-api/pkg/graphschema/patch.go +++ b/opengin/core-api/pkg/graphschema/patch.go @@ -48,9 +48,13 @@ type EndpointRef struct { // never modified. // // Removals are applied first (endpoint pairs, then relationships, then kinds), followed by -// additions, and the result is checked with Validate. If anything is wrong, Apply returns a nil -// schema and every violation found: patch violations have paths into the patch (e.g. -// "remove.kinds[0]"), and violations from Validate have paths into the resulting schema. +// additions, and the result is checked with Validate. Rules that a later step of the same patch +// can satisfy (a relationship's last pair, or a Major used through "Major/*" losing its last +// minor) are checked after the additions, so a patch can replace them in one go. +// +// If anything is wrong, Apply returns a nil schema and every violation found: patch violations +// have paths into the patch (e.g. "remove.kinds[0]"), and violations from Validate have paths +// into the resulting schema. func Apply(base *Schema, patch *Patch) (*Schema, []Violation) { if base == nil { return nil, []Violation{{Path: "", Message: "there is no schema to patch"}} @@ -59,13 +63,18 @@ func Apply(base *Schema, patch *Patch) (*Schema, []Violation) { return nil, []Violation{{Path: "", Message: "patch is required"}} } - p := &patcher{schema: cloneSchema(base), emptiedBy: map[string]int{}} + p := &patcher{ + schema: cloneSchema(base), + emptiedBy: map[string]int{}, + droppedMajors: map[string]droppedMajor{}, + } p.removeEndpoints(patch.Remove.Endpoints) p.removeRelationships(patch.Remove.Relationships) p.removeKinds(patch.Remove.Kinds) p.addKindDefs(patch.Add.KindDefs) p.addRelationshipDefs(patch.Add.RelationshipDefs) + p.checkDroppedMajors() p.checkEmptiedRelationships() if patch.Description != "" { @@ -86,6 +95,14 @@ type patcher struct { // emptiedBy records, for each relationship whose endpoint pairs were all removed, the index // of the last remove.endpoints entry that removed one of them. emptiedBy map[string]int + // droppedMajors records each Major whose last minor was removed, keyed by Major name. + droppedMajors map[string]droppedMajor +} + +// droppedMajor is a Major that a patch removed by removing its last minor. +type droppedMajor struct { + removeIndex int // index of the remove.kinds entry that removed the last minor + ref string // that entry, e.g. "Organization/Minister" } func (p *patcher) addViolation(path string, format string, args ...interface{}) { @@ -146,8 +163,8 @@ func (p *patcher) removeRelationships(names []string) { } } -// removeKinds removes kinds that no remaining endpoint uses. A kind that is still used is kept, -// so the only violation reported for it is the one that says why. +// removeKinds removes kinds that no remaining endpoint references exactly. A kind that is still +// used is kept, so the only violation reported for it is the one that says why. func (p *patcher) removeKinds(refs []string) { for k, ref := range refs { path := fmt.Sprintf("remove.kinds[%d]", k) @@ -164,8 +181,9 @@ func (p *patcher) removeKinds(refs []string) { continue } - dropsMajor := len(p.schema.KindDefs[i].Minors) == 1 - if user, found := p.findEndpointUsing(ref, major, dropsMajor); found { + // An exact reference can't be satisfied by anything added later, so it is checked now. + // "Major/*" references are checked in checkDroppedMajors, after additions. + if user, found := p.findEndpointUsing(ref); found { p.addViolation(path, "kind %s is still used by %s; remove that pair or relationship in the same patch", ref, user) continue } @@ -175,18 +193,17 @@ func (p *patcher) removeKinds(refs []string) { kindDef.Minors = append(kindDef.Minors[:j], kindDef.Minors[j+1:]...) if len(kindDef.Minors) == 0 { p.schema.KindDefs = append(p.schema.KindDefs[:i], p.schema.KindDefs[i+1:]...) + p.droppedMajors[major] = droppedMajor{removeIndex: k, ref: ref} } } } -// findEndpointUsing describes the first endpoint that references ref. When removing ref would -// drop its whole Major, a "Major/*" reference also counts as a use. -func (p *patcher) findEndpointUsing(ref string, major string, dropsMajor bool) (string, bool) { - wildcard := FormatKindRef(major, AnyMinor) +// findEndpointUsing describes the first endpoint with a side exactly equal to ref. +func (p *patcher) findEndpointUsing(ref string) (string, bool) { for _, relationshipDef := range p.schema.RelationshipDefs { for _, endpoint := range relationshipDef.Endpoints { for _, side := range []string{endpoint.From, endpoint.To} { - if side == ref || (dropsMajor && side == wildcard) { + if side == ref { return fmt.Sprintf("relationship %q pair %s -> %s", relationshipDef.Name, endpoint.From, endpoint.To), true } } @@ -243,6 +260,38 @@ func (p *patcher) addRelationshipDefs(relationshipDefs []RelationshipDef) { } } +// checkDroppedMajors reports Majors that the patch removed (by removing their last minor) while +// an endpoint still uses them through "Major/*", unless an addition in the same patch declared +// the Major again. The Major is put back in the result so Validate does not report the wildcard +// endpoint a second time. +func (p *patcher) checkDroppedMajors() { + majors := make([]string, 0, len(p.droppedMajors)) + for major := range p.droppedMajors { + majors = append(majors, major) + } + // report in patch order + sort.Slice(majors, func(a, b int) bool { + return p.droppedMajors[majors[a]].removeIndex < p.droppedMajors[majors[b]].removeIndex + }) + + for _, major := range majors { + if p.findKindDef(major) >= 0 { + continue + } + dropped := p.droppedMajors[major] + user, found := p.findEndpointUsing(FormatKindRef(major, AnyMinor)) + if !found { + continue + } + p.addViolation(fmt.Sprintf("remove.kinds[%d]", dropped.removeIndex), + "kind %s is still used by %s, as it is the last minor of %s; "+ + "add a replacement minor or remove that pair or relationship in the same patch", + dropped.ref, user, major) + _, minor, _ := ParseKindRef(dropped.ref) + p.schema.KindDefs = append(p.schema.KindDefs, KindDef{Major: major, Minors: []string{minor}}) + } +} + // checkEmptiedRelationships reports relationships left with no endpoint pairs because the patch // removed them all, without removing the relationship itself or adding a replacement pair. The // empty relationship is dropped from the result so Validate does not report it a second time. diff --git a/opengin/core-api/pkg/graphschema/patch_test.go b/opengin/core-api/pkg/graphschema/patch_test.go index 67661e56..86bb7e0f 100644 --- a/opengin/core-api/pkg/graphschema/patch_test.go +++ b/opengin/core-api/pkg/graphschema/patch_test.go @@ -319,6 +319,74 @@ func TestApplyKindRemovalWithWildcardReference(t *testing.T) { } } +func TestApplyReplacingLastMinorUsedByWildcard(t *testing.T) { + newBase := func() *Schema { + return &Schema{ + KindDefs: []KindDef{ + {Major: "Organization", Minors: []string{"Minister"}}, + {Major: "region", Minors: []string{"country"}}, + }, + RelationshipDefs: []RelationshipDef{ + {Name: "located_in", Endpoints: []Endpoint{{From: "Organization/*", To: "region/country"}}}, + }, + } + } + + t.Run("replacement minor added in the same patch", func(t *testing.T) { + result, violations := Apply(newBase(), &Patch{ + Remove: PatchRemovals{Kinds: []string{"Organization/Minister"}}, + Add: PatchAdditions{KindDefs: []KindDef{{Major: "Organization", Minors: []string{"Ministry"}}}}, + }) + if assert.Empty(t, violations) { + assert.Equal(t, []string{"Ministry"}, findKindDef(result, "Organization").Minors) + assert.Equal(t, []Endpoint{{From: "Organization/*", To: "region/country"}}, result.AllowedPairs("located_in")) + } + }) + + t.Run("no replacement minor", func(t *testing.T) { + result, violations := Apply(newBase(), &Patch{ + Remove: PatchRemovals{Kinds: []string{"Organization/Minister"}}, + }) + assert.Nil(t, result) + // Only the patch violation: Validate must not also report Organization/* as unknown. + if assert.Len(t, violations, 1, "got %v", violations) { + assert.Equal(t, "remove.kinds[0]", violations[0].Path) + assert.Contains(t, violations[0].Message, + `kind Organization/Minister is still used by relationship "located_in" pair Organization/* -> region/country, as it is the last minor of Organization`) + } + }) + + t.Run("wildcard pair removed in the same patch", func(t *testing.T) { + result, violations := Apply(newBase(), &Patch{ + Remove: PatchRemovals{ + Kinds: []string{"Organization/Minister"}, + Relationships: []string{"located_in"}, + }, + }) + if assert.Empty(t, violations) { + assert.Nil(t, findKindDef(result, "Organization")) + } + }) + + t.Run("exact reference is still rejected even with a replacement", func(t *testing.T) { + base := newBase() + base.RelationshipDefs = append(base.RelationshipDefs, RelationshipDef{ + Name: "reports_to", Endpoints: []Endpoint{{From: "Organization/Minister", To: "region/country"}}, + }) + + result, violations := Apply(base, &Patch{ + Remove: PatchRemovals{Kinds: []string{"Organization/Minister"}}, + Add: PatchAdditions{KindDefs: []KindDef{{Major: "Organization", Minors: []string{"Ministry"}}}}, + }) + assert.Nil(t, result) + if assert.Len(t, violations, 1, "got %v", violations) { + assert.Equal(t, "remove.kinds[0]", violations[0].Path) + assert.Contains(t, violations[0].Message, + `kind Organization/Minister is still used by relationship "reports_to" pair Organization/Minister -> region/country; remove that pair`) + } + }) +} + func TestApplyNilInputs(t *testing.T) { result, violations := Apply(nil, &Patch{}) assert.Nil(t, result) From 708eebb400b55ff593eed223f4877d63cd50d3f5 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 16:33:56 +0530 Subject: [PATCH 10/11] Enhance schema cloning and patch application tests to ensure proper handling of nil and empty slices --- opengin/core-api/pkg/graphschema/patch.go | 10 +++++ .../core-api/pkg/graphschema/patch_test.go | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/opengin/core-api/pkg/graphschema/patch.go b/opengin/core-api/pkg/graphschema/patch.go index 374613df..e397edd7 100644 --- a/opengin/core-api/pkg/graphschema/patch.go +++ b/opengin/core-api/pkg/graphschema/patch.go @@ -318,6 +318,16 @@ func (p *patcher) checkEmptiedRelationships() { // cloneSchema returns a deep copy of s, so patching never changes the caller's schema. func cloneSchema(s *Schema) *Schema { clone := &Schema{Description: s.Description} + + // Keep nil and empty slices distinct, so the clone serializes exactly like the original + // (null vs []). The inner slices keep it through slices.Clone. + if s.KindDefs != nil { + clone.KindDefs = make([]KindDef, 0, len(s.KindDefs)) + } + if s.RelationshipDefs != nil { + clone.RelationshipDefs = make([]RelationshipDef, 0, len(s.RelationshipDefs)) + } + for _, kindDef := range s.KindDefs { clone.KindDefs = append(clone.KindDefs, KindDef{ Major: kindDef.Major, diff --git a/opengin/core-api/pkg/graphschema/patch_test.go b/opengin/core-api/pkg/graphschema/patch_test.go index 86bb7e0f..fb719168 100644 --- a/opengin/core-api/pkg/graphschema/patch_test.go +++ b/opengin/core-api/pkg/graphschema/patch_test.go @@ -477,3 +477,40 @@ func TestPatchJSON(t *testing.T) { assert.Equal(t, []string{"local_government"}, patch.Remove.Relationships) assert.Equal(t, EndpointRef{Name: "located_in", From: "Organization/Government", To: "region/country"}, patch.Remove.Endpoints[0]) } + +func TestCloneSchemaPreservesNilAndEmptySlices(t *testing.T) { + empty := &Schema{ + KindDefs: []KindDef{{Major: "region", Minors: []string{}}}, + RelationshipDefs: []RelationshipDef{}, + } + clone := cloneSchema(empty) + assert.NotNil(t, clone.RelationshipDefs) + assert.Empty(t, clone.RelationshipDefs) + assert.NotNil(t, clone.KindDefs[0].Minors) + assert.Equal(t, empty, clone) + + missing := &Schema{KindDefs: []KindDef{{Major: "region"}}} + clone = cloneSchema(missing) + assert.Nil(t, clone.RelationshipDefs) + assert.Nil(t, clone.KindDefs[0].Minors) + assert.Equal(t, missing, clone) + + assert.Nil(t, cloneSchema(&Schema{}).KindDefs) +} + +func TestApplyEmptyPatchKeepsEmptyRelationshipDefsAsArray(t *testing.T) { + base := &Schema{ + KindDefs: []KindDef{{Major: "region", Minors: []string{"country"}}}, + RelationshipDefs: []RelationshipDef{}, + } + + result, violations := Apply(base, &Patch{}) + if !assert.Empty(t, violations) { + return + } + + encoded, err := json.Marshal(result) + if assert.NoError(t, err) { + assert.Contains(t, string(encoded), `"relationshipDefs":[]`) + } +} From 9ab5bb3108249b45fb6b5bfc5a08ebda608badb7 Mon Sep 17 00:00:00 2001 From: zaeema-n Date: Fri, 6 Nov 2026 16:40:52 +0530 Subject: [PATCH 11/11] Enhance validation logic for minor kind references in graphschema, adding tests for slashes in minor kinds --- opengin/core-api/pkg/graphschema/validate.go | 12 +++++++++++- opengin/core-api/pkg/graphschema/validate_test.go | 8 ++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/opengin/core-api/pkg/graphschema/validate.go b/opengin/core-api/pkg/graphschema/validate.go index fcc581f8..cee2c049 100644 --- a/opengin/core-api/pkg/graphschema/validate.go +++ b/opengin/core-api/pkg/graphschema/validate.go @@ -185,7 +185,17 @@ func validateKindRef(path string, ref string, declared declaredKinds, addViolati return } - if minor == AnyMinor || minors[AnyMinor] || minors[minor] { + // An explicitly declared minor is accepted even if it breaks a rule: kindDefs already reported + // it, and reporting it again for every endpoint would only repeat the same mistake. + if minors[minor] { + return + } + // A "*" declaration accepts any minor, but never one that could not be declared. + if strings.Contains(minor, KindSeparator) { + addViolation(path, "minor kind %q must not contain %q", minor, KindSeparator) + return + } + if minor == AnyMinor || minors[AnyMinor] { return } addViolation(path, "unknown kind %s", ref) diff --git a/opengin/core-api/pkg/graphschema/validate_test.go b/opengin/core-api/pkg/graphschema/validate_test.go index 7197ce8f..7fd53363 100644 --- a/opengin/core-api/pkg/graphschema/validate_test.go +++ b/opengin/core-api/pkg/graphschema/validate_test.go @@ -162,6 +162,14 @@ func TestValidateKindReferences(t *testing.T) { from: "Organization/Minister", to: "Place/City", expectedPath: "relationshipDefs[0].endpoints[0].to", expected: `Major "Place" is not declared`, }, + "minor with slash under a wildcard Major": { + from: "Person/a/b", to: "Organization/Minister", + expectedPath: "relationshipDefs[0].endpoints[0].from", expected: `minor kind "a/b" must not contain "/"`, + }, + "minor with slash under a Major with explicit minors": { + from: "Organization/Minister", to: "Organization/Minister/East", + expectedPath: "relationshipDefs[0].endpoints[0].to", expected: `minor kind "Minister/East" must not contain "/"`, + }, "empty from": { from: "", to: "Organization/Minister", expectedPath: "relationshipDefs[0].endpoints[0].from", expected: "kind is required",