diff --git a/projects/neuroglia/angular-data-source-graphql/src/lib/graphql-data-source.spec.ts b/projects/neuroglia/angular-data-source-graphql/src/lib/graphql-data-source.spec.ts index 3577944..846aa9e 100644 --- a/projects/neuroglia/angular-data-source-graphql/src/lib/graphql-data-source.spec.ts +++ b/projects/neuroglia/angular-data-source-graphql/src/lib/graphql-data-source.spec.ts @@ -104,12 +104,6 @@ const countSelector = (graphqlResponse: any): number => graphqlResponse?.data?.a const dataSelector = (graphqlResponse: any): any[] => graphqlResponse?.data?.allPlanets?.planets || []; -const responseMapper = (graphqlResponse: any): ODataQueryResultDto => ({ - '@odata.context': 'unkown', - '@odata.count': countSelector(graphqlResponse), - value: dataSelector(graphqlResponse), -}); - const expectedPlanetsResponse = { data: { allPlanets: { diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.spec.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.spec.ts new file mode 100644 index 0000000..aad7f74 --- /dev/null +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.spec.ts @@ -0,0 +1,1241 @@ +import { TestBed } from '@angular/core/testing'; +import { GraphQLMetadataService } from './graphql-metadata.service'; +import { provideHttpClient } from '@angular/common/http'; +import { NamedLoggingServiceFactory } from '@neuroglia/angular-logging'; +import { HttpErrorObserverService, UrlHelperService } from '@neuroglia/angular-rest-core'; +import { printSchema } from 'graphql'; +import { switchMap } from 'rxjs'; +import { ColumnDefinition } from '@neuroglia/angular-ngrx-component-store-queryable-table'; + +const testEndpoint = 'https://swapi-graphql.netlify.app/.netlify/functions/index'; + +const target = 'allPlanets'; + +const subField = 'planets'; + +const expectedColumnNames = [ + 'climates', + 'created', + 'diameter', + 'edited', + 'gravity', + 'id', + 'name', + 'orbitalPeriod', + 'population', + 'rotationPeriod', + 'surfaceWater', + 'terrains', +].toSorted(); + +const expectedSchema = `schema { + query: Root +} + +type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node +} + +"""A connection to a list of items.""" +type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""Information about pagination in a connection.""" +type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String +} + +"""An edge in a connection.""" +type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single film.""" +type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""An object with an ID""" +interface Node { + """The id of the object.""" + id: ID! +} + +"""A connection to a list of items.""" +type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A type of person or character within the Star Wars Universe.""" +type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +""" +A large mass, planet or planetoid in the Star Wars Universe, at the time of +0 ABY. +""" +type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occurring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] +} + +"""An edge in a connection.""" +type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""An individual person or character within the Star Wars universe.""" +type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that has hyperdrive capability.""" +type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +"""A single transport craft that does not have hyperdrive capability""" +type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! +} + +"""A connection to a list of items.""" +type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] +} + +"""An edge in a connection.""" +type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] +} + +"""An edge in a connection.""" +type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] +} + +"""An edge in a connection.""" +type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] +} + +"""An edge in a connection.""" +type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] +} + +"""An edge in a connection.""" +type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] +} + +"""An edge in a connection.""" +type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] +} + +"""An edge in a connection.""" +type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! +} + +"""A connection to a list of items.""" +type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] +} + +"""An edge in a connection.""" +type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! +}`; + +describe('GraphQL Metadata Service', () => { + let service: GraphQLMetadataService; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), NamedLoggingServiceFactory, HttpErrorObserverService, UrlHelperService], + }); + service = TestBed.inject(GraphQLMetadataService); + }); + + describe('getMetadataFromIntrospection', () => { + it('should build schema from introspection query', (done) => { + service.getMetadataFromIntrospection(testEndpoint).subscribe({ + next: (schema) => { + expect(printSchema(schema)).toEqual(expectedSchema); + done(); + }, + error: (err) => { + expect(err).withContext('error').toBeNull(); + done(); + }, + }); + }); + }); + + describe('getColumnDefinitions', () => { + it('should return column definitions for query "allPlanets"', (done) => { + service + .getMetadataFromIntrospection(testEndpoint) + .pipe(switchMap((_) => service.getColumnDefinitions(target, subField))) + .subscribe({ + next: (definitions: ColumnDefinition[]) => { + expect(definitions).not.toBeNull(); + expect(definitions.length).toBe(expectedColumnNames.length); + expect(definitions.map((def) => def.name).toSorted()).toEqual(expectedColumnNames); + done(); + }, + error: (err) => { + expect(err).withContext('error').toBeNull(); + done(); + }, + }); + }); + }); +}); diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.ts index dd9327e..a4f4d73 100644 --- a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.ts +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-metadata.service.ts @@ -1,10 +1,31 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { NamedLoggingServiceFactory } from '@neuroglia/angular-logging'; +import { ColumnDefinition } from '@neuroglia/angular-ngrx-component-store-queryable-table'; import { HttpErrorObserverService, HttpRequestInfo, logHttpRequest } from '@neuroglia/angular-rest-core'; import { ILogger } from '@neuroglia/logging'; -import { GraphQLSchema, Source, buildSchema } from 'graphql'; -import { Observable, map } from 'rxjs'; +import { + buildSchema, + getNamedType, + getNullableType, + getIntrospectionQuery, + Source, + GraphQLSchema, + GraphQLList, + GraphQLEnumType, + GraphQLScalarType, + GraphQLObjectType, + IntrospectionQuery, + buildClientSchema, +} from 'graphql'; +import { Observable, map, of } from 'rxjs'; +import { FieldInfo } from './models'; +import { isSet } from '@neuroglia/common'; + +/** Guards the provided type is 'GraphQLObjectType' */ +function isObjectType(type: any | null | undefined): type is GraphQLObjectType { + return type instanceof GraphQLObjectType; +} @Injectable({ providedIn: 'root', @@ -19,25 +40,209 @@ export class GraphQLMetadataService { /** The logger instance */ protected readonly logger: ILogger = this.namedLoggingServiceFactory.create('GraphQLMetadataService'); /** The GraphQL schema */ - protected schema: GraphQLSchema | null; + protected metadata: GraphQLSchema | null; /** - * Gathers the schema from the provided service + * Gathers the schema from the provided URL * @param schemaUrl * @returns */ - getMetadata(schemaUrl: string): Observable { + getMetadataFromSchema(schemaUrl: string): Observable { const httpRequestInfo: HttpRequestInfo = new HttpRequestInfo({ - clientServiceName: 'ODataMetadataService', - methodName: 'metadata$', + clientServiceName: 'GraphQLMetadataService', + methodName: schemaUrl, verb: 'get', url: schemaUrl, }); return logHttpRequest(this.logger, this.errorObserver, this.http.get(schemaUrl), httpRequestInfo).pipe( map((schemaDefinition: string) => { - this.schema = buildSchema(new Source(schemaDefinition)); - return this.schema; + this.metadata = buildSchema(new Source(schemaDefinition)); + return this.metadata; }), ); } + /** + * Builds the schema from the provided URL using introspection + * @param serviceEndpoint + * @returns + */ + getMetadataFromIntrospection(serviceEndpoint: string): Observable { + const introspectionQuery = getIntrospectionQuery(); + const httpRequestInfo: HttpRequestInfo = new HttpRequestInfo({ + clientServiceName: 'GraphQLMetadataService', + methodName: 'introspection', + verb: 'post', + url: serviceEndpoint, + }); + return logHttpRequest( + this.logger, + this.errorObserver, + this.http.post<{ data: IntrospectionQuery }>(serviceEndpoint, { query: introspectionQuery }), + httpRequestInfo, + ).pipe( + map((introspectionResponse: { data: IntrospectionQuery }) => { + this.metadata = buildClientSchema(introspectionResponse.data); + return this.metadata; + }), + ); + } + + /** + * Gets the column definitions for the returned type of the provided query + * @param target The query to gather the definitions from + * @param subObject A nested object to gather the fields of, e.g.: 'pager.results' + * @returns + */ + getColumnDefinitions(target: string, subField: string = ''): Observable { + return of( + this.getQueryFieldsInfo(target, subField, true).map( + ({ name, type, isNullable, isNavigationProperty, isCollection, isEnum, enumValues }, index) => { + const columnDefinition: ColumnDefinition = { + name, + type, + position: index + 1, + isNullable, + isNavigationProperty, + isCollection, + isEnum, + isVisible: !isNavigationProperty && !isCollection, + isSortable: !isNavigationProperty && !isCollection, + isFilterable: !isNavigationProperty && !isCollection, + }; + if (columnDefinition.isEnum) { + columnDefinition.type = 'String'; + columnDefinition.enumValues = enumValues; + } + return columnDefinition; + }, + ), + ); + } + + getFieldsQuery(target: string, subField: string = '', maxDepth: number = 5): Observable { + return of(this.getFieldsInfoQuery(this.getQueryFieldsInfo(target, subField, false, maxDepth))); + } + + getArgsQuery(target: string): Observable { + if (!this.metadata) { + throw new Error( + `Schema must be initialized first with 'getMetadataFromSchema' or 'getMetadataFromIntrospection'`, + ); + } + const Query = this.metadata.getQueryType(); + if (!isSet(Query)) { + throw new Error(`The current schema does't expose any Query root object.`); + } + const queryType = this.metadata.getType(Query.name); + if (!isObjectType(queryType)) { + throw new Error(`The Query root object does't seem to be of type 'GraphQLObjectType'.`); + } + const queries = queryType.getFields(); + const targetQuery = queries[target]; + if (!isSet(targetQuery)) { + throw new Error(`The target query '${target}' cannot be found.`); + } + + return of(''); + } + + protected getFieldsInfoQuery(fieldsInfo: Array): string { + return fieldsInfo.reduce((acc, info) => { + acc += info.name; + if (!info.fields.length) { + acc += '\n'; + return acc; + } + acc += ' {\n'; + acc += this.getFieldsInfoQuery(info.fields); + acc += '}\n'; + return acc; + }, ''); + } + + protected getQueryFieldsInfo( + target: string, + subField: string = '', + includeEmptyObjects: boolean = false, + maxDepth: number = 5, + ): Array { + if (!this.metadata) { + throw new Error( + `Schema must be initialized first with 'getMetadataFromSchema' or 'getMetadataFromIntrospection'`, + ); + } + const Query = this.metadata.getQueryType(); + if (!isSet(Query)) { + throw new Error(`The current schema does't expose any Query root object.`); + } + const queryType = this.metadata.getType(Query.name); + if (!isObjectType(queryType)) { + throw new Error(`The Query root object does't seem to be of type 'GraphQLObjectType'.`); + } + const queries = queryType.getFields(); + const targetQuery = queries[target]; + if (!isSet(targetQuery)) { + throw new Error(`The target query '${target}' cannot be found.`); + } + const returnType = getNamedType(targetQuery.type); + if (!isObjectType(returnType)) { + throw new Error(`The return type of the query '${target}' is not of type 'GraphQLObjectType'.`); + } + const subFields = subField.split('.'); + const depth = subFields.length + maxDepth; + let fieldsInfo: Array | undefined = this.getFieldsInfo(returnType, includeEmptyObjects, depth); + if (subField.length) { + for (let field of subFields) { + fieldsInfo = fieldsInfo.find((info) => info.name === field)?.fields; + if (!isSet(fieldsInfo)) { + throw new Error(`Impossible to find info for subField '${subField}'`); + } + } + } + return fieldsInfo; + } + + /** + * Gathers the @see {@link FieldInfo}s of the provided @see {@link GraphQLObjectType} + * @param targetType + * @param includeEmptyObjects Defines if an object should be kept even if its fields haven't been crawled or don't contrain scalar values, default false + * Used to prevent queries such as: + * `myObject { + * }` + * @param maxDepth + * @param currentDepth + */ + protected getFieldsInfo( + targetType: GraphQLObjectType, + includeEmptyObjects: boolean = false, + maxDepth: number = 1, + currentDepth: number = 1, + ): Array { + if (!isObjectType(targetType)) { + throw new Error(`Expected target type to be a 'GraphQLObjectType'`); + } + return Object.values(targetType.getFields()) + .filter((field) => !field.args?.length) + .map((field) => { + const { name, type } = field; + const namedType = getNamedType(type); + const info: FieldInfo = { + name, + type: namedType.name, + isNullable: field.type === getNullableType(field.type), + isNavigationProperty: !(namedType instanceof GraphQLScalarType || namedType instanceof GraphQLEnumType), + isCollection: field.type instanceof GraphQLList, + isEnum: namedType instanceof GraphQLEnumType, + fields: [], + enumValues: (namedType as GraphQLEnumType).getValues + ? (namedType as GraphQLEnumType).getValues().map((member) => member.value) + : [], + }; + if (info.isNavigationProperty && maxDepth > currentDepth && isObjectType(namedType)) { + info.fields = this.getFieldsInfo(namedType, includeEmptyObjects, maxDepth, currentDepth + 1); + } + return info; + }) + .filter((info) => includeEmptyObjects || !info.isNavigationProperty || !!info.fields.length); + } } diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-table.store.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-table.store.ts new file mode 100644 index 0000000..d3ffe1d --- /dev/null +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/graphql-table.store.ts @@ -0,0 +1,96 @@ +import { Injectable, Injector, inject } from '@angular/core'; +import { + ColumnDefinition, + IQueryableTableStore, + QueryableTableState, + QueryableTableStore, +} from '@neuroglia/angular-ngrx-component-store-queryable-table'; +import { + GRAPHQL_DATA_SOURCE_ENDPOINT, + GRAPHQL_DATA_SOURCE_TARGET, + GraphQLDataSource, +} from 'projects/neuroglia/angular-data-source-graphql/src/public-api'; +import { GraphQLMetadataService } from './graphql-metadata.service'; +import { Observable, filter, map, of, switchMap, takeUntil } from 'rxjs'; +import { isSet } from '@neuroglia/common'; +import { QueryableTableConfig } from '@neuroglia/angular-ngrx-component-store-queryable-table'; +import { GraphQLSchema } from 'graphql'; + +@Injectable() +export class GraphQLTableStore< + TState extends QueryableTableState = QueryableTableState, + TData = any, + TConfig extends QueryableTableConfig = QueryableTableConfig, + > + extends QueryableTableStore + implements IQueryableTableStore +{ + /** Holds the datasource instance */ + protected dataSource: GraphQLDataSource | null; + + /** The @see {@link GraphQLMetadataService} */ + protected graphQLMetadataService = inject(GraphQLMetadataService); + + constructor() { + super(); + } + + /** @inheritdoc */ + protected getServiceDataEnpoint(config: TConfig): string { + if (!isSet(config.serviceUrl)) { + throw new Error('Missing GraphQL service URL.'); + } + return config.serviceUrl; + } + + /** @inheritdoc */ + protected getColumnDefinitions(config: TConfig): Observable { + return !config.useMetadata + ? of(config.columnDefinitions).pipe( + filter((definitions: ColumnDefinition[] | undefined) => !!definitions?.length), + map((definitions: ColumnDefinition[] | undefined) => definitions as ColumnDefinition[]), + ) + : this.graphQLMetadataService.getMetadataFromIntrospection(config.serviceUrl!).pipe( + takeUntil(this.destroy$), + switchMap((_: GraphQLSchema) => + this.graphQLMetadataService.getColumnDefinitions(config.targetType || config.target!), + ), + map((definitions: ColumnDefinition[]) => { + const stateDefinitionNames = (config.columnDefinitions || []).map((def) => def.name); + const columnDefinitions = [ + ...definitions.filter((def) => !stateDefinitionNames.includes(def.name)), + ...(config.columnDefinitions || []).map((stateDef) => { + const def = definitions.find((def) => def.name === stateDef.name); + if (!def) { + return stateDef; + } + const columnDefinition = { ...def, ...stateDef }; + return columnDefinition; + }), + ]; + return columnDefinitions as ColumnDefinition[]; + }), + ); + } + + /** @inheritdoc */ + protected getStringType(): string { + return 'String'; + } + + /** @inheritdoc */ + protected injectDataSource(config: TConfig): Observable> { + const dataUrl = this.get((state) => state.dataUrl); + const target = this.get((state) => state.target); + const dataSourceInjector = Injector.create({ + name: 'DataSourceInjector', + parent: this.injector, + providers: [ + GraphQLDataSource, + { provide: GRAPHQL_DATA_SOURCE_ENDPOINT, useValue: dataUrl }, + { provide: GRAPHQL_DATA_SOURCE_TARGET, useValue: target }, + ], + }); + return dataSourceInjector.get(GraphQLDataSource) as GraphQLDataSource; + } +} diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/field-info.interface.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/field-info.interface.ts new file mode 100644 index 0000000..b20bee3 --- /dev/null +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/field-info.interface.ts @@ -0,0 +1,21 @@ +/** + * Represents the information relative to an object field + */ +export interface FieldInfo { + /** The name of the field */ + name: string; + /** The type of the field */ + type: string; + /** True if the field is nullable */ + isNullable: boolean; + /** True if the field is an object type */ + isNavigationProperty: boolean; + /** True if the field is a list type */ + isCollection: boolean; + /** True if the field is a enum type */ + isEnum: boolean; + /** The list of sub fields, if any */ + fields: Array; + /** The list of possible enum values, if any */ + enumValues: Array; +} diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/graphql-table.state.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/graphql-table.state.ts new file mode 100644 index 0000000..9aa8857 --- /dev/null +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/graphql-table.state.ts @@ -0,0 +1,10 @@ +import { Metadata } from './odata-metadata'; +import { QueryableTableState } from '@neuroglia/angular-ngrx-component-store-queryable-table'; + +/** + * Represents the state of an OData table + */ +export interface ODataTableState extends QueryableTableState { + /** The OData service metadata */ + metadata: Metadata | null; +} diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/index.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/index.ts new file mode 100644 index 0000000..ce240fc --- /dev/null +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/lib/models/index.ts @@ -0,0 +1 @@ +export * from './field-info.interface'; diff --git a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/public-api.ts b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/public-api.ts index a0e3af1..72af3f6 100644 --- a/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/public-api.ts +++ b/projects/neuroglia/angular-ngrx-component-store-graphql-table/src/public-api.ts @@ -1,3 +1,4 @@ /* * Public API Surface of angular-ngrx-component-store-graphql-table */ +export * from './lib/graphql-metadata.service';