Skip to content

Commit 1510262

Browse files
authored
Merge pull request #473 from walmartlabs/hls/20260601-interface-implements
Support interface implementing another interface (closes #464)
2 parents f7eba10 + 1c3a819 commit 1510262

8 files changed

Lines changed: 628 additions & 654 deletions

File tree

java/com/walmartlabs/lacinia/GraphqlSchema.interp

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

java/com/walmartlabs/lacinia/GraphqlSchemaParser.java

Lines changed: 394 additions & 624 deletions
Large diffs are not rendered by default.

resources/com/walmartlabs/lacinia/GraphqlSchema.g4

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ inputValueDef
102102
;
103103

104104
interfaceDef
105-
: description? K_INTERFACE anyName directiveList? fieldDefs?
105+
: description? K_INTERFACE anyName implementationDef? directiveList? fieldDefs?
106106
;
107107

108108
scalarDef

src/com/walmartlabs/lacinia/introspection.clj

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,19 @@
5353
(defn ^:private resolve-interfaces
5454
[context _ object]
5555
(let [{:keys [::category ::type-def]} object]
56-
(when (= :object category)
56+
(cond
57+
;; For objects, always return the (possibly-empty) list of implemented interfaces.
58+
(= :object category)
5759
(let [interfaces (-> type-def :implements sort seq)
5860
schema (get context constants/schema-key)]
5961
(map #(type-name->schema-type schema %)
60-
interfaces)))))
62+
interfaces))
63+
;; For interfaces, return the list only when they implement other interfaces.
64+
(= :interface category)
65+
(when-let [interfaces (-> type-def :implements sort seq)]
66+
(let [schema (get context constants/schema-key)]
67+
(map #(type-name->schema-type schema %)
68+
interfaces))))))
6169

6270
(defn ^:private is-deprecated?
6371
"The :deprecated key can either be a boolean, or a string which is the deprecation reason."

src/com/walmartlabs/lacinia/parser/schema.clj

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,13 +400,14 @@
400400

401401
(defmethod xform :interfaceDef
402402
[prod]
403-
(let [{:keys [anyName fieldDefs description directiveList]
403+
(let [{:keys [anyName implementationDef fieldDefs description directiveList]
404404
:or {fieldDefs (list :fieldDefs)}} (tag prod)]
405405
[[:interfaces (xform anyName)]
406406
(-> {:fields (xform fieldDefs)}
407407
(common/copy-meta anyName)
408408
(apply-description description)
409-
(apply-directives directiveList))]))
409+
(apply-directives directiveList)
410+
(cond-> implementationDef (assoc :implements (xform implementationDef))))]))
410411

411412
(defmethod xform :unionDef
412413
[prod]

src/com/walmartlabs/lacinia/schema.clj

Lines changed: 126 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,8 @@
354354
;; Here we'd prefer a version of ::fields where :resolve was not defined.
355355
(s/def ::interface (s/keys :opt-un [::description
356356
::directives
357-
::fields]))
357+
::fields
358+
::implements]))
358359
;; A list of keyword identifying objects that are part of a union.
359360
(s/def ::members (s/and (s/coll-of ::type-name)
360361
seq))
@@ -688,6 +689,13 @@
688689
[i-type f-type]
689690
(contains? (:implements f-type) (:type-name i-type)))
690691

692+
(defmethod check-compatible [:interface :interface]
693+
[i-type f-type]
694+
;; An interface field type is compatible with an implementing-interface field type if
695+
;; the implementing interface (f-type) declares that it implements the constraining
696+
;; interface (i-type).
697+
(contains? (:implements f-type) (:type-name i-type)))
698+
691699
;; That's as far as the spec goes, but one could imagine additonal rules
692700
;; such as a union-vs-union (the field union must be a subset of the interface union),
693701
;; or interface-union (all members of the union must implement the interface).
@@ -1558,12 +1566,51 @@
15581566
map->Type
15591567
compile-directives)))
15601568

1569+
(defn ^:private expand-implements
1570+
"Returns the transitive closure of implements for a type. Walks up the interface
1571+
hierarchy so that e.g. if B implements A, and C implements B, C's :implements
1572+
includes both :B and :A."
1573+
[schema type-name]
1574+
(loop [result #{}
1575+
queue (vec (:implements (get schema type-name)))]
1576+
(if (empty? queue)
1577+
result
1578+
(let [iface-name (first queue)
1579+
remaining (rest queue)
1580+
iface (get schema iface-name)]
1581+
(recur (conj result iface-name)
1582+
(into (vec remaining)
1583+
(remove result (:implements iface))))))))
1584+
15611585
(defmethod compile-type :interface
15621586
[interface schema]
1563-
(->> interface
1564-
map->Interface
1565-
compile-directives
1566-
(compile-fields schema)))
1587+
(let [interface-name (:type-name interface)
1588+
implements (->> interface :implements (map as-keyword) set)]
1589+
(doseq [iface-name implements
1590+
:let [type (get schema iface-name)]]
1591+
(when (= iface-name interface-name)
1592+
(throw (ex-info (format "Interface %s cannot implement itself."
1593+
(q iface-name))
1594+
{:interface interface-name})))
1595+
(when-not type
1596+
(throw (ex-info (format "Interface %s implements interface %s, which does not exist."
1597+
(q interface-name)
1598+
(q iface-name))
1599+
{:interface interface-name
1600+
:schema-types (type-map schema)})))
1601+
(when-not (= :interface (:category type))
1602+
(throw (ex-info (format "Interface %s implements type %s, which is not an interface."
1603+
(q interface-name)
1604+
(q iface-name))
1605+
{:interface interface-name
1606+
:schema-types (type-map schema)}))))
1607+
(->> interface
1608+
map->Interface
1609+
compile-directives
1610+
(compile-fields schema)
1611+
(#(if (seq implements)
1612+
(assoc % :implements implements)
1613+
%)))))
15671614

15681615
(defn ^:private extract-type-name
15691616
"Navigates a type map down to the root kind and returns the type name."
@@ -1722,28 +1769,83 @@
17221769
;; Validate argument directives
17231770
(validate-directives-in-def schema arg-def :argument-definition)))))
17241771

1772+
(defn ^:private all-implemented-interfaces
1773+
"Returns the transitive set of interface names implemented by the given type (by type-name keyword).
1774+
Walks up the interface hierarchy via :implements on each compiled interface definition."
1775+
[schema type-name]
1776+
(loop [result #{}
1777+
queue (vec (:implements (get schema type-name)))]
1778+
(if (empty? queue)
1779+
result
1780+
(let [iface-name (first queue)
1781+
remaining (subvec (vec queue) 1)]
1782+
(if (result iface-name)
1783+
(recur result remaining)
1784+
(recur (conj result iface-name)
1785+
(into remaining
1786+
(remove result (:implements (get schema iface-name))))))))))
1787+
17251788
(defn ^:private prepare-and-validate-interfaces
17261789
"Invoked after compilation to add a :members set identifying which concrete types implement
1727-
the interface. Peforms final verification of types in fields and field arguments."
1790+
the interface. Performs final verification of types in fields and field arguments.
1791+
Also validates that interfaces implementing other interfaces declare all required fields."
17281792
[schema]
1729-
(let [objects (types-with-category schema :object)]
1730-
(map-types schema :interface
1731-
(fn [interface]
1732-
(verify-fields-and-args schema interface)
1733-
(validate-directives-in-def schema interface :interface)
1734-
(let [interface-name (:type-name interface)
1735-
implementors (->> objects
1736-
(filter #(-> % :implements interface-name))
1737-
(map :type-name)
1738-
set)
1739-
fields' (->> interface
1740-
:fields
1741-
(map-vals #(assoc % :type-name interface-name))
1742-
(map-vals apply-deprecated-directive))]
1743-
(-> interface
1744-
(assoc :members implementors
1745-
:fields fields')
1746-
(dissoc :resolve)))))))
1793+
(let [objects (types-with-category schema :object)
1794+
interfaces (types-with-category schema :interface)
1795+
;; Detect cycles in the interface implements graph before doing anything else.
1796+
_ (doseq [interface interfaces
1797+
:let [interface-name (:type-name interface)
1798+
transitive (all-implemented-interfaces schema interface-name)]]
1799+
(when (transitive interface-name)
1800+
(throw (ex-info (format "Interface %s is part of a circular implements chain."
1801+
(q interface-name))
1802+
{:interface interface-name}))))
1803+
;; Expand each object's :implements set to include transitively-inherited interfaces.
1804+
;; This is needed so check-compatible [:interface :object] works when an object only
1805+
;; directly lists a sub-interface but not its parent interfaces.
1806+
schema' (reduce (fn [s obj]
1807+
(let [transitive (all-implemented-interfaces s (:type-name obj))
1808+
expanded (into (:implements obj #{}) transitive)]
1809+
(if (= expanded (:implements obj))
1810+
s
1811+
(update s (:type-name obj) assoc :implements expanded))))
1812+
schema
1813+
objects)]
1814+
;; Validate that each interface implementing another interface declares all required fields.
1815+
(doseq [interface interfaces
1816+
:let [interface-name (:type-name interface)]
1817+
parent-name (:implements interface)
1818+
:let [parent (get schema parent-name)]
1819+
[field-name parent-field] (:fields parent)
1820+
:let [iface-field (get-nested interface [:fields field-name])]]
1821+
(when-not iface-field
1822+
(throw (ex-info "Missing interface field in interface definition."
1823+
{:interface interface-name
1824+
:field-name field-name
1825+
:parent-interface-name parent-name})))
1826+
(when-not (is-assignable? schema parent-field iface-field)
1827+
(throw (ex-info "Interface field is not compatible with implemented interface field type."
1828+
{:parent-interface-name parent-name
1829+
:field-name (:qualified-name iface-field)}))))
1830+
(let [objects' (types-with-category schema' :object)]
1831+
(map-types schema' :interface
1832+
(fn [interface]
1833+
(verify-fields-and-args schema' interface)
1834+
(validate-directives-in-def schema' interface :interface)
1835+
(let [interface-name (:type-name interface)
1836+
;; Use objects' (with expanded :implements) to catch transitive membership.
1837+
implementors (->> objects'
1838+
(filter #(-> % :implements interface-name))
1839+
(map :type-name)
1840+
set)
1841+
fields' (->> interface
1842+
:fields
1843+
(map-vals #(assoc % :type-name interface-name))
1844+
(map-vals apply-deprecated-directive))]
1845+
(-> interface
1846+
(assoc :members implementors
1847+
:fields fields')
1848+
(dissoc :resolve))))))))
17471849

17481850
(defn ^:private update-fields-in-object
17491851
[object-def f]

test/com/walmartlabs/interface_test.clj

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,76 @@
9999
(is (some? (compile compatible-field-nullability))
100100
"Object fields are allowed to be non-null, even if the interface field is nullable.")))
101101

102+
(def interface-implements-interface
103+
'{:interfaces {:node {:fields {:id {:type (non-null String)}}}
104+
:resource {:implements [:node]
105+
:fields {:id {:type (non-null String)}
106+
:url {:type String}}}}
107+
:objects {:article {:implements [:resource]
108+
:fields {:id {:type (non-null String)}
109+
:url {:type String}
110+
:title {:type String}}}}})
111+
112+
(deftest interface-can-implement-interface
113+
(is (some? (compile interface-implements-interface))
114+
"schema with interface implementing interface should compile"))
115+
116+
(deftest object-transitively-implements-parent-interface
117+
(let [compiled (compile interface-implements-interface)]
118+
;; :article implements :resource which implements :node.
119+
;; :article should be a member of both :node and :resource.
120+
(is (contains? (get-in compiled [:node :members]) :article)
121+
"article should be a member of :node (transitively via :resource)")
122+
(is (contains? (get-in compiled [:resource :members]) :article)
123+
"article should be a member of :resource (directly)")))
124+
125+
(deftest interface-implements-interface-missing-field
126+
(let [invalid-schema (assoc-in interface-implements-interface
127+
[:interfaces :resource :fields]
128+
{:url {:type 'String}})]
129+
;; :resource implements :node but doesn't declare :id
130+
(expect-exception
131+
"Missing interface field in interface definition."
132+
{:interface :resource
133+
:field-name :id
134+
:parent-interface-name :node}
135+
(compile invalid-schema))))
136+
137+
(deftest interface-circular-implements-fails
138+
(testing "direct cycle (A implements B, B implements A)"
139+
(let [invalid-schema '{:interfaces {:A {:implements [:B]
140+
:fields {:id {:type String}}}
141+
:B {:implements [:A]
142+
:fields {:id {:type String}}}}}]
143+
(is (thrown-with-msg? Throwable #"circular implements chain"
144+
(compile invalid-schema)))))
145+
146+
(testing "indirect cycle (A implements B, B implements C, C implements A)"
147+
(let [invalid-schema '{:interfaces {:A {:implements [:B]
148+
:fields {:id {:type String}}}
149+
:B {:implements [:C]
150+
:fields {:id {:type String}}}
151+
:C {:implements [:A]
152+
:fields {:id {:type String}}}}}]
153+
(is (thrown-with-msg? Throwable #"circular implements chain"
154+
(compile invalid-schema))))))
155+
156+
(deftest interface-cannot-implement-itself
157+
(let [invalid-schema '{:interfaces {:node {:implements [:node]
158+
:fields {:id {:type String}}}}}]
159+
(expect-exception
160+
"Interface `node' cannot implement itself."
161+
{:interface :node}
162+
(compile invalid-schema))))
163+
164+
(deftest interface-implements-non-interface-fails
165+
;; :resource tries to implement :article which is an object, not an interface
166+
(let [invalid-schema '{:interfaces {:node {:fields {:id {:type String}}}
167+
:resource {:implements [:article]
168+
:fields {:id {:type String}}}}
169+
:objects {:article {:implements [:node]
170+
:fields {:id {:type String}}}}}]
171+
(is (thrown-with-msg? Throwable
172+
#"Interface `resource' implements type `article', which is not an interface."
173+
(compile invalid-schema)))))
174+

test/com/walmartlabs/lacinia/parser/schema_test.clj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@
180180
{:type 'String}}}}}
181181
(parse-string "interface Flow { ebb : String }"))))
182182

183+
(deftest schema-interface-implements-interface
184+
(is (= {:interfaces
185+
{:Node {:fields {:id {:type 'ID}}}
186+
:Post {:fields {:id {:type 'ID}
187+
:title {:type 'String}}
188+
:implements [:Node]}}}
189+
(parse-string "interface Node { id: ID } interface Post implements Node { id: ID title: String }"))))
190+
191+
(deftest schema-interface-implements-multiple-interfaces
192+
(is (= {:interfaces
193+
{:Node {:fields {:id {:type 'ID}}}
194+
:Timestamped {:fields {:createdAt {:type 'String}}}
195+
:Post {:fields {:id {:type 'ID}
196+
:createdAt {:type 'String}
197+
:title {:type 'String}}
198+
:implements [:Node :Timestamped]}}}
199+
(parse-string (str "interface Node { id: ID } "
200+
"interface Timestamped { createdAt: String } "
201+
"interface Post implements Node & Timestamped { id: ID createdAt: String title: String }")))))
202+
183203
(deftest schema-union
184204

185205
(testing "basic union type"

0 commit comments

Comments
 (0)