Objetos embebidos en GraphQL

Tona
ComeOnCommunity
Published in
11 min readNov 2, 2017

Existen ocasiones en las que nuestro modelo de base de datos posee referencias a otros modelos. Estas referencias suelen estar representadas sólo por un ID, sin embargo, se llegan a dar los casos en los que al realizar una consulta sobre el modelo, es necesario obtener no sólo el ID del modelo referenciado si no toda la información de dicha referencia.

A continuación te muestro una forma en la que se puede realizar la solicitud de modelos embebidos haciendo uso de GraphQL sin necesidad de hacer otra petición al servicio.

Notas y Consideraciones

Esta guía da por hecho que conoces de qué va GraphQL y que lo has implementado en alguna ocasión.

Las tecnologías que utilizaremos son las siguientes:

  • NodeJS v8.1.0
  • Apollo Server v0.8.0 y sus herramientas
  • Koa v2.2.0
  • MongoDB 3.4

Para más detalles sobre las librerías utilizadas, favor de revisar el archivo package.json del proyecto ejemplo, el cual puedes encontrar aquí.

Estructura del proyecto

Rápidamente me gustaría explicarte cómo está ordenado el código del proyecto ejemplo.

Dentro del directorio principal del proyecto encontraremos los siguientes directorios: config, models, resolvers, schema, types y utils. Revisemos cada uno de ellos.

config

En el directorio config veremos el archivo system_variables.env, éste contiene los valores de las variables utilizadas a nivel sistema como: accesos de servicios, URLs, IP’s, etcétera; es decir, todas las variables con valores sensibles o que cambian de acuerdo al entorno en el que se encuentra corriendo el servicio y que por tal motivo no deben ser registradas por el sistema de control de versiones.

Además del archivo de variables de sistema, encontramos el directorio system, el cual contiene los archivos de configuración de los frameworks utilizados en el proyecto. En este caso tendremos la configuración del servidor Koa y del ODM Mongoose.

models

En models es donde se almacenan los modelos de las colecciones utilizadas por el sistema.

schema

En este directorio tendremos sólo tres archivos: mutations.js, queries.js y schema.js. En mutations.js y queries.js se definen por medio de un string todas las mutaciones y consultas que nuestro servicio tendrá, y en el archivo schema.js se define el schema del servicio GraphQL.

resolvers

Aquí tenemos tres directorios: mutations, queries y types. Cada uno de estos directorios contiene la lógica encargada de obtener la información solicitada por medio de GraphQL, ya sea de una base de datos o de un servicio externo. Cabe señalar que además de estos directorios y estas lógicas, encontraremos los archivos index.js, uno a nivel de los tres directorios y otro en cada uno de ellos. De lo que se encargan estos archivos es de concatenar todas las lógicas para poder devolverlas como una serie de funciones que se alinean a la definición del schema de GraphQL.

types

En este directorio se encuentran todos los types e inputs que las mutaciones y queries definidos en el directorio schema requieren, teniendo un archivo por cada entidad y un archivo index.js encargado de concatenar todas estas definiciones.

utils

En el directorio utils se almacena el código que se puede reutilizar en todo el proyecto.

Por último, me gustaría mencionar que esta estructura del código no obedece a ningún estándar o regla, sino que fue elaborado con base en la experiencia y las necesidades del proyecto.

Definiendo la base de datos

Para esta guía haremos uso de cuatro entidades: Teacher, Student, Group y Course, las cuales están relacionadas entre sí de la siguiente manera.

Como podemos ver, entre las entidades se dan dos clases de relaciones, siendo la primera una relación del tipo muchos a muchos y la segunda de uno a muchos. Estas relaciones a nivel colección de MongoDB estarán definidas por un arreglo de IDs y un campo de tipo ID respectivamente.

Comencemos definiendo por completo los modelos de las colecciones de nuestra base de datos. Haciendo uso del framework mongoose, los modelos quedan de la siguiente manera:

Teacher Model

// teacher_model.js
const mongoose = require('mongoose');
const teacherSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
last_name: { type: String, trim: true },
groups: [{
_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Group'}
}],
courses: [{
_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Course'}
}]
}, { collection: 'teacher' });
module.exports = mongoose.model('Teacher', teacherSchema);

Student Model

// student_model.js
const mongoose = require('mongoose');
const studentSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
last_name: { type: String, required: true, trim: true },
group: { type: mongoose.Schema.Types.ObjectId, ref: 'Group'}
}, { collection: 'student' });
module.exports = mongoose.model('Student', studentSchema);

Group Model

// group_model.js
const mongoose = require('mongoose');
const groupSchema = new mongoose.Schema({
denomination: { type: String, required: true, trim: true }
}, { collection: 'group' });
module.exports = mongoose.model('Group', groupSchema);

Course Model

// course_model.js
const mongoose = require('mongoose');
const courseSchema = new mongoose.Schema({
denomination: { type: String, required: true, trim: true }
}, { collection: 'course' });
module.exports = mongoose.model('Course', courseSchema);

Una vez definidos los modelos, pasaremos a crear el CRUD de cada uno de ellos.

Creando las Consultas y Mutaciones

Para la creación del servicio haremos uso de Koa y Apollo Server. Koa nos servirá para crear el servidor web que se encargará de recibir las peticiones http, y Apollo Server será el encargado de recibir dichas peticiones y resolverlas, además de ayudarnos en la definición del schema, las consultas y las mutaciones.

Teacher

Vamos a comenzar con los CRUDS de la entidad Teacher, éste será el ejemplo de cómo realizar la consulta embebida en una relación de muchos a muchos. Primero definimos el schema.

Schema

// schema.js
module.exports = '
schema {
query: Query
mutation: Mutation
}
';

Definimos el tipo Query y Mutation del Schema junto con las mutaciones y consultas para Teacher.

Mutaciones Teacher

// mutations.js
module.exports = '
type Mutation {
createTeacher(input: TeacherCreate): Teacher
updateTeacher(input: TeacherUpdate): Teacher
deleteTeacher(_id: ID!): Boolean
}
';

Consultas Teacher

// queries.js
module.exports = '
type Query {
teacher(_id: ID!): Teacher
teacherList: [Teacher]
}
';

Cabe señalar que el archivo schema.js ya no sufrirá ningún cambio, al contrario de mutation.js y queries.js que son donde se agregarán todas las demás mutaciones y consultas de las entidades restantes.

A continuación definiremos los types e inputs que hacen uso de las mutaciones y las consultas.

Tipos Teacher

// teacher_type.js
module.exports = '
type Teacher {
_id: ID
name: String
last_name: String
groups: [Group]
courses: [Course]
}
input TeacherGroups {
_id: ID
}
input TeacherCourses {
_id: ID
}
input TeacherCreate {
name: String!
last_name: String
groups: [TeacherGroups]
courses: [TeacherCourses]
}
input TeacherUpdate {
_id: ID!
name: String
last_name: String
groups: [TeacherGroups]
courses: [TeacherCourses]
}
';

Como podemos ver, tenemos tres mutaciones (createTeacher, updateTeacher, deleteTeacher) y dos consultas (teacher, teacherList), además de los types (Teacher) e inputs (TeacherGroups, TeacherCourses, TeacherCreate, TeacherUpdate), y cada atributo de éstos corresponden a los atributos del modelo Teacher. Es importante tener esto en cuenta ya que afecta directamente a la forma en la que se declaran los resolvers y a la lógica que trae la información o la modifica, ya que los nombres de las funciones deben corresponder a los nombres de las mutaciones y consultas, de lo contrario el schema no logrará ubicar el resolver.

¡Ah! Pero antes de continuar me voy a salir un poco de la entidad Teacher ya que es necesario agregar unos types que vamos a necesitar, éstos corresponden a las entidades Course y Group, definición que no estamos cubriendo aquí pero que puedes revisar en el proyecto demo.

Tipos Course

// course_type.js
module.exports = '
type Course {
_id: ID
denomination: String
}
';

Tipos Group

// group_type.js
module.exports = '
type Group {
_id: ID
denomination: String
}
';

La explicación de estos tipos y de su importancia la veremos más adelante.

Resolvers

Ahora vamos a construir los resolvers para las mutaciones y consultas de nuestro schema. Recordemos que los resolvers son los encargados de obtener la información o modificarla.

Resolvers Mutaciones

// mutations/teacher_resolver.js
const TeacherModel = require ('./../../models/teacher_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function createTeacher(root, args, context) {
const teacher = new TeacherModel({
name: args.input.name,
last_name: args.input.last_name,
groups: args.input.groups,
courses: args.input.courses
});
return teacher.save()
.then(newTeacher => newTeacher)
.catch((error) => {
logger.error(error);
return error;
});
}
function updateTeacher(root, args, context) {
return TeacherModel.findById(args.input._id).exec()
.then((foundTeacher) => {
if (!foundTeacher) {
throw new Error('Teacher Not Found');
}
const teacherData = foundTeacher;
const input = args.input;
teacherData.name = input.name || teacherData.name;
teacherData.last_name = input.last_name || teacherData.last_name;
teacherData.groups = input.groups || teacherData.groups;
teacherData.courses = input.courses || teacherData.courses;
return teacherData.save();
})
.then(updatedTeacher => updatedTeacher)
.catch((error) => {
logger.error(error);
return error;
});
}
function deleteTeacher(root, args, context) {
return TeacherModel.findById(args._id).exec()
.then((foundTeacher) => {
if (!foundTeacher) {
throw new Error('Teacher Not Found');
}
return foundTeacher.remove();
})
.then(removedTeacher => removedTeacher)
.catch((error) => {
logger.error(error);
return error;
});
}
module.exports = {
createTeacher: createTeacher,
updateTeacher: updateTeacher,
deleteTeacher: deleteTeacher
}

Resolvers Consultas

// queries/teacher_resolver.js
const getProjection = require('./../../utils/get_projection');
const TeacherModel = require ('./../../models/teacher_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function teacher(root, args, context, _info) {
const projection = getProjection(_info.fieldNodes[0]);
return TeacherModel.findById(args._id).select(projection).exec()
.then((foundTeacher) => {
if (!foundTeacher) {
throw new Error('Teacher Not Found');
}
return foundTeacher;
})
.catch((error) => {
logger.error(error);
return error;
});
}
function teacherList(root, args, context, _info) {
const projection = getProjection(_info.fieldNodes[0]);
return TeacherModel.find().select(projection).exec();
}
module.exports = {
teacher: teacher,
teacherList: teacherList
}

Para la lógica de cada resolver no tenemos otra cosa más que sencillas consultas a MongoDB y una que otra validación, nada que con una buena leída de código no se pueda entender, sin embargo todavía nos falta agregar el resolver encargado de realizar la consulta al objeto embebido y esto lo haremos en los párrafos siguientes.

Resolvers Objetos Embebidos

Recordemos que la entidad Teacher tiene dos relaciones del tipo muchos a muchos con las entidades Group y Courses, lo que se traduce en que varios maestros pueden estar relacionados a muchos grupos y cursos, esto expresado en GraphQL queda de la siguiente manera.

'type Teacher {
_id: ID
name: String
last_name: String
groups: [Group]
courses: [Course]
}'

Lo importante a notar en esta definición es que el campo groups y courses son del tipo arreglo, los cuales almacenan objetos de tipo Group y Course, cuyas definiciones, recordemos, son las siguientes.

'type Group {
_id: ID
denomination: String
students: [GroupStudent]
}'
'type Course {
_id: ID
denomination: String
teachers: [CourseTeacher]
}'

Al tener las definiciones de esta manera le estamos indicando a GraphQL que, además del resolver que regresa la lista de Teacher, tendremos dos resolvers más que regresarán la lista de groups y curses, lo que hace necesario agregar la lógica de estos.

Estos resolvers, a diferencia de los resolvers principales, vivirán en el directorio resolvers/types los cuales sólo se centrarán en resolver las peticiones de los objetos embebidos.

// resolvers/types/teacher_resolver.js
const getProjection = require('./../../utils/get_projection');
const GroupModel = require('./../../models/group_model');
const CourseModel = require('./../../models/course_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function groups(root, args, context, _info) {
const arrayOfPromises = [];
const PROJECTION = getProjection(_info.fieldNodes[0]);
root.groups.forEach((groupId) => {
arrayOfPromises.push(GroupModel.findById(groupId)
.select(PROJECTION).exec());
});
return Promise.all(arrayOfPromises);
}
function courses(root, args, context, _info) {
const arrayOfPromises = [];
const PROJECTION = getProjection(_info.fieldNodes[0]);
root.courses.forEach((courseId) => {
arrayOfPromises.push(CourseModel.findById(courseId)
.select(PROJECTION).exec());
});
return Promise.all(arrayOfPromises);
}
module.exports = {
groups: groups,
courses: courses
};

Otra diferencia que encontramos en estos resolvers es que deben ser declarados en objetos diferentes con el nombre de la entidad que pertenece, en este caso Teacher, contrario a los resolvers principales que viven dentro del objeto Query.

// resolvers/queries/index.js
const teacherQueryResolvers = require('./teacher_resolver');
const queriesResolvers = Object.assign({}, teacherQueryResolvers);module.exports = {
Query: queriesResolvers,
};
// resolvers/types/index.js
const teacherTypeResolvers = require('./teacher_resolver');
module.exports = {
Teacher: teacherTypeResolvers
};

¡Listo! Con esto podremos consultar los maestros con todos sus grupos asignados y cursos.

Student

Para mostrar cómo se construyen los resolvers para las relaciones de uno a muchos, utilizaremos la entidad Student, sólo que en esta ocasión nos centraremos en la definición en GraphQL y en los resolvers del objeto embebido.

Mutaciones Student

// schema/mutations.js
module.exports = '
type Mutation {
createTeacher(input: TeacherCreate): Teacher
updateTeacher(input: TeacherUpdate): Teacher
deleteTeacher(_id: ID!): Boolean
createStudent(input: StudentCreate): Student
updateStudent(input: StudentUpdate): Student
deleteStudent(_id: ID!): Boolean
}
';

Consultas Student

// schema/queries.js
module.exports = '
type Query {
teacher(_id: ID!): Teacher
teacherList: [Teacher]
student(_id: ID!): Student
studentList: [Student]
}
';

Tipos Student

// types/student_types.js
module.exports = '
type Student {
_id: ID
name: String
last_name: String
group: Group
}
input StudentCreate {
name: String!
last_name: String
group: ID
}
input StudentUpdate {
_id: ID!
name: String
last_name: String
group: ID
}
';

Veamos cómo esta definición es muy parecida a la definición de las relaciones muchos a muchos, la única diferencia es que en el campo group no tenemos el arreglo, sino sólo la referencia al type Group.

Resolver Objeto Embebido

// types/student_resolver.js
const getProjection = require('./../../utils/get_projection');
const GroupModel = require('./../../models/group_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function group(root, args, context, _info) {
const PROJECTION = getProjection(_info.fieldNodes[0]);
return GroupModel.findById(root.group).select(PROJECTION).exec()
.then(foundStudent => foundStudent)
.catch((error) => {
logger.error(error);
return null;
});
}
module.exports = {
group: group
};
// types/index.js
const teacherTypeResolvers = require('./teacher_resolver');
const studentTypeResolvers = require('./student_resolver');
module.exports = {
Teacher: teacherTypeResolvers,
Student: studentTypeResolvers
};

Y ¡púmbale! Ya con esto podemos solicitar el grupo en las consultas de estudiantes. :)

Bonus

Ya con este recurso en nuestras manos, podemos embeber objetos en la definición GraphQL a pesar de que éstos no se encuentren así en el modelo de base de datos, sólo nos tenemos que asegurar de que el resolver entregue la información que la definición especifica. Pero no lo dejemos sólo en palabras y hagamos un ejemplo.

Regresemos a los modelos de Group y Courses, éstos sólo cuentan con el atributo *denomination* así que vamos a agregarles a ambos la funcionalidad de poder regresar los Students y Techears. Para esto agregamos dos nuevos tipos en la definición de GraphQL, así como el campo que los contendrá.

Tipos Course

// course_type.js
module.exports = '
type CourseTeacher {
_id: ID
name: String
last_name: String
}
type Course {
_id: ID
denomination: String
teachers: [CourseTeacher]
}
';

Tipos Group

// group_type.js
module.exports = '
type GroupStudent {
_id: ID
name: String
last_name: String
}
type Group {
_id: ID
denomination: String
students: [GroupStudent]
}
';

Con agregar el campo teachers y students, ambos de tipo arreglo, le estamos indicando a GraphQL que a estos types se les puede solicitar esta información, pero a diferencia de los campos courses y group de los types Teacher y Student, donde utilizábamos los types Group y Course ya definidos, fue necesario crear otro par de types: GroupStudent y CourseTeacher, los cuales cuentan con casi todos los atributos de los types principales, omitiendo sólo la referencia a courses y group, con esto evitamos que se embeban estos objetos infinitamente.

Por último, para terminar este bonus, creamos los resolvers.

Resolvers Objectos Embebidos

// resolvers/types/group_resolvers.js
const getProjection = require('./../../utils/get_projection');
const StudentModel = require('./../../models/student_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function students(root, args, context, _info) {
const arrayOfPromises = [];
const PROJECTION = getProjection(_info.fieldNodes[0]);
return StudentModel.find({ group: root._id }).select(PROJECTION).exec()
.then(students => students)
.catch((error) => {
logger.error(error);
return null;
});
}
module.exports = {
students: students
};
// resolvers/types/course_resolvers.js
const getProjection = require('./../../utils/get_projection');
const TeacherModel = require('./../../models/teacher_model');
const Logger = require('./../../utils/logger');
const logger = new Logger();function teachers(root, args, context, _info) {
const arrayOfPromises = [];
const PROJECTION = getProjection(_info.fieldNodes[0]);
return TeacherModel.find({courses: {'$elemMatch': {_id: root._id}}}).select(PROJECTION).exec()
.then(teachers => teachers)
.catch((error) => {
logger.error(error);
return null;
});
}
module.exports = {
teachers: teachers
};

Y ¡voilà! Tenemos objetos embebidos en modelos de bases de datos que no los tienen.

Conclusión

Y así terminamos esta guía, espero te sea de gran ayuda. Danos unas palmas y comparte para que más amiguitos desarrolladores conozcan esta propiedad de GraphQL.

Cualquier duda, corrección o sugerencia por favor ponla en los comentarios para darle el seguimiento requerido ñ_ñ

--

--

Tona
ComeOnCommunity

Programador de tiempo completo, otaku de closet, intento de guitarrista y amante de las bicis...