Skip to content

Commit

Permalink
Aggregation
Browse files Browse the repository at this point in the history
 * This PR enables you to query data on the server with a schema that does not match a database collection - for example an aggregation or an array of objects
 * PR for the documentation is [here](VulcanJS/vulcan-docs#169)
 * Added new function `registerCustomQuery()`, to add a custom GraphQL type generated from a schema object and a resolver to query the data
 * Added new function `registerCustomDefaultFragment()`, to generate a default fragment from the same schema
 * Updated `multi2` to be compatible with custom queries - you can now specify a `typeName` instead of a `collection`
 * Updated `createSchema()` to support [SimpleSchema Shorthand Definitions](https://github.com/aldeed/simpl-schema#shorthand-definitions)
 * Added `defaultCanRead` parameter to `createSchema()`
  • Loading branch information
ErikDakoda committed Feb 11, 2021
1 parent f330b1b commit 37e6c40
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 84 deletions.
171 changes: 102 additions & 69 deletions packages/vulcan-lib/lib/modules/graphql/defaultFragment.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,93 +2,126 @@
* Generates the default fragment for a collection
* = a fragment containing all fields
*/
import { getFragmentFieldNames } from '../schema_utils';
import { getFragmentFieldNames, createSchema } from '../schema_utils';
import { isBlackbox } from '../simpleSchema_utils';
import { registerFragment } from '../fragments.js';


const intlSuffix = '_intl';

// get fragment for a whole object (root schema or nested schema of an object or an array)
const getObjectFragment = ({
schema,
fragmentName,
options
export const getObjectFragment = ({
schema,
fragmentName,
options,
}) => {
const fieldNames = getFragmentFieldNames({ schema, options });
const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({
schema,
fieldName,
options,
getObjectFragment: getObjectFragment
}))
// remove empty values
.filter(f => !!f);
if (childFragments.length) {
return `${fragmentName} { ${childFragments.join('\n')} }`;
}
return null;
const fieldNames = getFragmentFieldNames({ schema, options });
const childFragments = fieldNames.length && fieldNames.map(fieldName => getFieldFragment({
schema,
fieldName,
options,
getObjectFragment: getObjectFragment,
}))
// remove empty values
.filter(f => !!f);
if (childFragments.length) {
return `${fragmentName} { ${childFragments.join('\n')} }`;
}
return null;
};

// get fragment for a specific field (either the field name or a nested fragment)
export const getFieldFragment = ({
schema,
fieldName,
options,
getObjectFragment = getObjectFragment // a callback to call on nested schema
schema,
fieldName,
options,
getObjectFragment = getObjectFragment, // a callback to call on nested schema
}) => {
// intl
if (fieldName.slice(-5) === intlSuffix) {
return `${fieldName}{ locale value }`;
}
if (fieldName === '_id') return fieldName;
const field = schema[fieldName];

const fieldType = field.type.singleType;
const fieldTypeName =
typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType;

switch (fieldTypeName) {
case 'Object':
if (!isBlackbox(field) && fieldType._schema) {
return getObjectFragment({
fragmentName: fieldName,
schema: fieldType._schema,
options
}) || null;
}
return fieldName;
case 'Array':
const arrayItemFieldName = `${fieldName}.$`;
const arrayItemField = schema[arrayItemFieldName];
// note: make sure field has an associated array item field
if (arrayItemField) {
// child will either be native value or a an object (first case)
const arrayItemFieldType = arrayItemField.type.singleType;
if (!isBlackbox(field) && arrayItemFieldType._schema) {
return getObjectFragment({
fragmentName: fieldName,
schema: arrayItemFieldType._schema,
options
}) || null;
}
}
return fieldName;
default:
return fieldName; // fragment = fieldName
}
// intl
if (fieldName.slice(-5) === intlSuffix) {
return `${fieldName}{ locale value }`;
}
if (fieldName === '_id') return fieldName;
const field = schema[fieldName];

const fieldType = field.type.singleType;
const fieldTypeName =
typeof fieldType === 'object' ? 'Object' : typeof fieldType === 'function' ? fieldType.name : fieldType;

switch (fieldTypeName) {
case 'Object':
if (!isBlackbox(field) && fieldType._schema) {
return getObjectFragment({
fragmentName: fieldName,
schema: fieldType._schema,
options,
}) || null;
}
return fieldName;
case 'Array':
const arrayItemFieldName = `${fieldName}.$`;
const arrayItemField = schema[arrayItemFieldName];
// note: make sure field has an associated array item field
if (arrayItemField) {
// child will either be native value or a an object (first case)
const arrayItemFieldType = arrayItemField.type.singleType;
if (!isBlackbox(field) && arrayItemFieldType._schema) {
return getObjectFragment({
fragmentName: fieldName,
schema: arrayItemFieldType._schema,
options,
}) || null;
}
}
return fieldName;
default:
return fieldName; // fragment = fieldName
}
};


/*
Create default "dumb" gql fragment object for a given collection
*/
export const getDefaultFragmentText = (collection, options = { onlyViewable: true }) => {
const schema = collection.simpleSchema()._schema;
return getObjectFragment({
schema,
fragmentName: `fragment ${collection.options.collectionName}DefaultFragment on ${collection.typeName}`,
options
}) || null;
const schema = collection.simpleSchema()._schema;
return getObjectFragment({
schema,
fragmentName: `fragment ${collection.options.collectionName}DefaultFragment on ${collection.typeName}`,
options,
}) || null;
};

export default getDefaultFragmentText;
export default getDefaultFragmentText;


/**
* Generates and registers a default fragment for a typeName registered using `registerCustomQuery()`
* @param {string} typeName The GraphQL Type registered using `registerCustomQuery()`
* @param {Object|SimpleSchema} [schema] Schema definition object to convert to a fragment
* @param {String} [fragmentName] The fragment's name; if omitted `${typeName}DefaultFragment` will be used
* @param {[String]} [defaultCanRead] Fields in the schema without `canRead` will be assigned these read permissions
* @param {Object} options Options sent to `getObjectFragment()`
*/
export const registerCustomDefaultFragment = function ({
typeName,
schema,
fragmentName,
defaultCanRead,
options = { onlyViewable: true },
}) {
const simpleSchema = createSchema(schema, undefined, undefined, defaultCanRead);
schema = simpleSchema._schema;

fragmentName = fragmentName || `${typeName}DefaultFragment`;

const defaultFragment = getObjectFragment({
schema,
fragmentName: `fragment ${fragmentName} on ${typeName}`,
options,
});

if (defaultFragment) registerFragment(defaultFragment);
};
2 changes: 1 addition & 1 deletion packages/vulcan-lib/lib/modules/graphql_templates/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type Movie{
*/
export const mainTypeTemplate = ({ typeName, description, interfaces, fields }) =>
`${description ? `# ${description}` : ''}
type ${typeName} ${interfaces.length ? `implements ${interfaces.join(', ')} ` : ''}{
type ${typeName} ${interfaces?.length ? `implements ${interfaces.join(', ')} ` : ''}{
${convertToGraphQL(fields, ' ')}
}
`;
47 changes: 34 additions & 13 deletions packages/vulcan-lib/lib/modules/schema_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _reject from 'lodash/reject';
import _keys from 'lodash/keys';
import { Collections } from './collections.js';
import { getNestedSchema, getArrayChild, isBlackbox } from 'meteor/vulcan:lib/lib/modules/simpleSchema_utils';
import MongoObject from 'mongo-object';
import _isArray from 'lodash/isArray';
import _get from 'lodash/get';
import _isEmpty from 'lodash/isEmpty';
Expand All @@ -23,11 +24,28 @@ export const formattedDateResolver = fieldName => {
};
};

export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => {
let modifiedSchema = { ...schema };
export const createSchema = (schema, apiSchema = {}, dbSchema = {}, defaultCanRead) => {
let modifiedSchema = schema._schema ? { ...schema._schema } : { ...schema };

Object.keys(modifiedSchema).forEach(fieldName => {
const field = schema[fieldName];
// support SimpleSchema Shorthand Definitions
// https://github.com/aldeed/simpl-schema#shorthand-definitions
if (Array.isArray(modifiedSchema[fieldName])) {
modifiedSchema[fieldName] = { type: Array, arrayItem: { type: modifiedSchema[fieldName][0] } };
if (modifiedSchema[fieldName].arrayItem.type === Object) {
modifiedSchema[fieldName].arrayItem.blackbox = true;
}
} else if (!MongoObject.isBasicObject(modifiedSchema[fieldName])) {
modifiedSchema[fieldName] = { type: modifiedSchema[fieldName] };
if (modifiedSchema[fieldName].type === Object) {
modifiedSchema[fieldName].blackbox = true;
}
}
if (!modifiedSchema[fieldName].canRead && defaultCanRead) {
modifiedSchema[fieldName].canRead = defaultCanRead;
}

const field = modifiedSchema[fieldName];
const { arrayItem, type, canRead } = field;

if (field.resolveAs) {
Expand Down Expand Up @@ -57,7 +75,8 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => {
// or as a resolveAs field, then add fieldFormatted to apiSchema
const formattedFieldName = `${fieldName}Formatted`;

if (type === Date && !schema[formattedFieldName] && !(_get(field, 'resolveAs.fieldName', '') === formattedFieldName)) {
if (type === Date && !modifiedSchema[formattedFieldName] &&
!(_get(field, 'resolveAs.fieldName', '') === formattedFieldName)) {
apiSchema[formattedFieldName] = {
typeName: 'String',
canRead,
Expand All @@ -71,7 +90,7 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => {
if (!_isEmpty(apiSchema)) {
Object.keys(apiSchema).forEach(fieldName => {
const field = apiSchema[fieldName];
const { canRead = ['guests'], description, ...resolveAs } = field;
const { canRead = defaultCanRead, description, ...resolveAs } = field;
modifiedSchema[fieldName] = {
type: Object,
optional: true,
Expand All @@ -85,7 +104,7 @@ export const createSchema = (schema, apiSchema = {}, dbSchema = {}) => {

// for added security, remove any API-related permission checks from db fields
const filteredDbSchema = {};
const blacklistedFields = [ 'canRead', 'canCreate', 'canUpdate'];
const blacklistedFields = ['canRead', 'canCreate', 'canUpdate'];
Object.keys(dbSchema).forEach(dbFieldName => {
filteredDbSchema[dbFieldName] = _omit(dbSchema[dbFieldName], blacklistedFields);
});
Expand Down Expand Up @@ -135,15 +154,16 @@ export const shouldAddOriginalField = (fieldName, field) => {
const resolveAsArray = Array.isArray(field.resolveAs) ? field.resolveAs : [field.resolveAs];

const removeOriginalField = resolveAsArray.some(
resolveAs => resolveAs.addOriginalField === false || resolveAs.fieldName === fieldName || typeof resolveAs.fieldName === 'undefined'
resolveAs => resolveAs.addOriginalField === false || resolveAs.fieldName === fieldName ||
typeof resolveAs.fieldName === 'undefined',
);
return !removeOriginalField;
};
// list fields that can be included in the default fragment for a schema
export const getFragmentFieldNames = ({ schema, options }) =>
export const getFragmentFieldNames = ({ schema, options = {} }) =>
_reject(_keys(schema), fieldName => {
/*
Exclude a field from the default fragment if
1. it has a resolver and original field should not be added
2. it has $ in its name
Expand All @@ -165,7 +185,7 @@ export const getFragmentFieldNames = ({ schema, options }) =>

/*
Check if a type corresponds to a collection or else
Check if a type corresponds to a collection or else
is just a regular or custom scalar type.
*/
Expand All @@ -174,12 +194,13 @@ export const isCollectionType = typeName =>

/**
* Iterate over a document fields and run a callback with side effect
* Works recursively for nested fields and arrays of objects (but excluding blackboxed objects, native JSON, and arrays of native values)
* Works recursively for nested fields and arrays of objects (but excluding blackboxed objects, native JSON, and
* arrays of native values)
* @param {*} document Current document
* @param {*} schema Document schema
* @param {*} callback Called on each field with the corresponding field schema, including fields of nested objects and arrays of nested object
* @param {*} callback Called on each field with the corresponding field schema, including fields of nested objects
* and arrays of nested object
* @param {*} currentPath Global path of the document (to track recursive calls)
* @param {*} isNested Differentiate nested fields
*/
export const forEachDocumentField = (document, schema, callback, currentPath = '') => {
if (!document) return;
Expand Down
4 changes: 3 additions & 1 deletion packages/vulcan-lib/lib/server/graphql/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './graphql';
export * from './typedefs';
export * from './registerCustomQuery';
export * from './schemaFields';
export * from './typedefs';
Loading

0 comments on commit 37e6c40

Please sign in to comment.