diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml index 63a4359..0607ec4 100644 --- a/.github/workflows/testsuite.yml +++ b/.github/workflows/testsuite.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - meteor: [ '2.13.3', '3.0.4', '3.1.2' ] + meteor: [ '3.2' ] # needs: [lintcode,lintstyle,lintdocs] # we could add prior jobs for linting, if desired steps: - name: checkout diff --git a/package/collection2/.versions b/package/collection2/.versions index dae2085..80b4f9b 100644 --- a/package/collection2/.versions +++ b/package/collection2/.versions @@ -1,45 +1,45 @@ -aldeed:collection2@4.1.4 +aldeed:collection2@4.2.0-beta.1 aldeed:simple-schema@1.13.1 allow-deny@2.1.0 -babel-compiler@7.11.3 +babel-compiler@7.12.2 babel-runtime@1.5.2 base64@1.0.13 binary-heap@1.0.12 -boilerplate-generator@2.0.0 -callback-hook@1.6.0 +boilerplate-generator@2.0.2 +callback-hook@1.6.1 check@1.4.4 core-runtime@1.0.0 ddp@1.4.2 -ddp-client@3.1.0 +ddp-client@3.1.1 ddp-common@1.4.4 ddp-server@3.1.2 diff-sequence@1.1.3 dynamic-import@0.7.4 -ecmascript@0.16.10 +ecmascript@0.16.13 ecmascript-runtime@0.8.3 ecmascript-runtime-client@0.12.3 ecmascript-runtime-server@0.11.1 -ejson@1.1.4 +ejson@1.1.5 facts-base@1.0.2 fetch@0.1.6 geojson-utils@1.0.12 id-map@1.2.0 inter-process-messaging@0.1.2 -local-test:aldeed:collection2@4.1.4 +local-test:aldeed:collection2@4.2.0-beta.1 logging@1.3.6 -meteor@2.1.0 +meteor@2.1.1 meteortesting:browser-tests@1.7.0 meteortesting:mocha@3.2.0 meteortesting:mocha-core@8.3.1-rc300.1 -minimongo@2.0.2 -modern-browsers@0.2.1 +minimongo@2.0.4 +modern-browsers@0.2.3 modules@0.20.3 modules-runtime@0.13.2 -mongo@2.1.1 +mongo@2.1.4 mongo-decimal@0.2.0 mongo-dev-server@1.1.1 mongo-id@1.0.9 -npm-mongo@6.10.2 +npm-mongo@6.16.1 ordered-dict@1.2.0 promise@1.0.0 raix:eventemitter@1.0.0 @@ -48,9 +48,9 @@ react-fast-refresh@0.2.9 reload@1.3.2 retry@1.1.1 routepolicy@1.1.2 -socket-stream-client@0.6.0 +socket-stream-client@0.6.1 tracker@1.3.4 -typescript@5.6.3 -webapp@2.0.6 +typescript@5.6.6 +webapp@2.0.7 webapp-hashing@1.1.2 zodern:types@1.0.13 diff --git a/package/collection2/adapters/ajv.js b/package/collection2/adapters/ajv.js new file mode 100644 index 0000000..58cb775 --- /dev/null +++ b/package/collection2/adapters/ajv.js @@ -0,0 +1,343 @@ +import { Meteor } from 'meteor/meteor'; +import { EJSON } from 'meteor/ejson'; +import { isInsertType } from '../lib'; +import { isAjvSchema } from '../schemaDetectors'; + +/** + * AJV adapter + * @returns {Object} AJV adapter implementation + */ +export const createAjvAdapter = () => ({ + name: 'ajv', + is: schema => isAjvSchema(schema), + create: schema => { + // If this is already an AJV schema, return it directly + if (schema && typeof schema === 'object' && (schema.definition || schema.$id)) { + // Enhance the schema with Collection2 compatibility methods + return enhanceAjvSchema(schema); + } + + // For non-AJV schemas, we can't convert without the actual AJV library + throw new Error('Cannot create AJV schema from non-AJV object. Please use an AJV schema directly.'); + }, + extend: (s1, s2) => { + // For property-based detection, we need to ensure both schemas have the right properties + if (!(s1 && (s1.definition || s1.$id)) || !(s2 && (s2.definition || s2.$id))) { + throw new Error('Both schemas must be AJV schemas'); + } + + // Since we don't have direct access to AJV's methods, we'll use a simplified approach + // In a real implementation, you'd merge the schemas properly + return enhanceAjvSchema(s2); // Simplified implementation - just use the second schema + }, + validate: (obj, schema, options = {}) => { + try { + // Handle modifiers for updates + if (options.modifier) { + // For now, just allow modifiers without validation + // In a real implementation, we would validate each modifier operation + return { isValid: true }; + } else { + // For normal documents (insert) + // In a real implementation, we would use AJV's validate method + // For now, we'll just do a simple check for required fields + const definition = schema.definition || schema; + let isValid = true; + const errors = []; + + if (definition.required && Array.isArray(definition.required)) { + for (const field of definition.required) { + if (obj[field] === undefined) { + isValid = false; + errors.push({ + name: field, + type: 'required', + value: undefined, + message: `${field} is required` + }); + } + } + } + + // Check property types + if (definition.properties && typeof definition.properties === 'object') { + for (const [field, propDef] of Object.entries(definition.properties)) { + if (obj[field] !== undefined) { + // Type validation + if (propDef.type === 'string' && typeof obj[field] !== 'string') { + isValid = false; + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a string` + }); + } else if (propDef.type === 'number' && typeof obj[field] !== 'number') { + isValid = false; + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a number` + }); + } else if (propDef.type === 'boolean' && typeof obj[field] !== 'boolean') { + isValid = false; + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a boolean` + }); + } + } + } + } + + return { + isValid, + errors + }; + } + } catch (error) { + // Handle any unexpected errors + return { + isValid: false, + errors: [{ name: 'general', type: 'error', message: error.message }] + }; + } + }, + clean: ({ doc, modifier, schema, userId, isLocalCollection, type }) => { + // AJV schemas don't have a built-in clean method, so we use our custom implementation + const isModifier = !isInsertType(type); + const target = isModifier ? modifier : doc; + + if (typeof schema.clean === 'function') { + schema.clean(target, { + mutate: true, + isModifier + }); + } + }, + getErrorObject: (context, appendToMessage = '', code) => { + const invalidKeys = context.validationErrors(); + + if (!invalidKeys || invalidKeys.length === 0) { + return new Error('Unknown validation error'); + } + + const firstErrorKey = invalidKeys[0].name; + const firstErrorMessage = invalidKeys[0].message; + let message = firstErrorMessage; + + // Fallback to standard message format + if (firstErrorKey.indexOf('.') === -1) { + message = `${firstErrorMessage}`; + } else { + message = `${firstErrorMessage} (${firstErrorKey})`; + } + + message = `${message} ${appendToMessage}`.trim(); + const error = new Error(message); + error.invalidKeys = invalidKeys; + error.validationContext = context; + error.code = code; + error.name = 'ValidationError'; // Set the name to ValidationError consistently + // If on the server, we add a sanitized error, too, in case we're + // called from a method. + if (Meteor.isServer) { + error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); + } + return error; + }, + getValidationContext: (schema, validationContext) => { + if (validationContext && typeof validationContext === 'object') { + return validationContext; + } + + // Ensure the schema is enhanced with Collection2 compatibility methods + const enhancedSchema = enhanceAjvSchema(schema); + return enhancedSchema.namedContext(validationContext); + }, + + // Enhance a schema with Collection2 compatibility methods + enhance: (schema) => { + return enhanceAjvSchema(schema); + }, + + freeze: false, + + // Add validation context handling directly to the adapter +}); + +/** + * Creates a validation context for AJV schemas + * @param {Object} schema - The AJV schema + * @param {String} name - Optional name for the context + * @returns {Object} A validation context compatible with Collection2 + */ +const createAjvValidationContext = (schema, name = 'default') => { + let errors = []; + + return { + validate: (obj, options = {}) => { + errors = []; // Clear previous errors + + try { + // Use the schema definition for validation + const definition = schema.definition || schema; + + // For modifiers, we need special handling + if (options.modifier) { + // For now, just allow modifiers without validation + // In a real implementation, we would validate each modifier operation + return true; + } else { + // For normal documents (insert) + // In a real implementation, we would use AJV's validate method + // For now, we'll just do a simple check for required fields + if (definition.required && Array.isArray(definition.required)) { + for (const field of definition.required) { + if (obj[field] === undefined) { + errors.push({ + name: field, + type: 'required', + value: undefined, + message: `${field} is required` + }); + } + } + } + + // Check property types + if (definition.properties && typeof definition.properties === 'object') { + for (const [field, propDef] of Object.entries(definition.properties)) { + if (obj[field] !== undefined) { + // Type validation + if (propDef.type === 'string' && typeof obj[field] !== 'string') { + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a string` + }); + } else if (propDef.type === 'number' && typeof obj[field] !== 'number') { + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a number` + }); + } else if (propDef.type === 'boolean' && typeof obj[field] !== 'boolean') { + errors.push({ + name: field, + type: 'type', + value: obj[field], + message: `${field} must be a boolean` + }); + } + } + } + } + + return errors.length === 0; + } + } catch (error) { + errors.push({ + name: 'general', + type: 'error', + value: null, + message: error.message || 'Validation failed' + }); + return false; + } + }, + validationErrors: () => { + return errors; + }, + invalidKeys: () => { + return errors; + }, + resetValidation: () => { + errors = []; + }, + isValid: () => { + return errors.length === 0; + }, + keyIsInvalid: (key) => { + return errors.some(err => err.name === key); + }, + keyErrorMessage: (key) => { + const error = errors.find(err => err.name === key); + return error ? error.message : ''; + } + }; +}; + +/** + * Enhances an AJV schema with Collection2 compatibility methods + * @param {Object} schema - The AJV schema to enhance + * @returns {Object} The enhanced schema + */ +const enhanceAjvSchema = (schema) => { + // Store validation contexts by name + const validationContexts = {}; + + // Add namedContext method if it doesn't exist + if (typeof schema.namedContext !== 'function') { + schema.namedContext = function(name = 'default') { + // Reuse existing context if available + if (validationContexts[name]) { + return validationContexts[name]; + } + + // Create and store a new context + const context = createAjvValidationContext(schema, name); + validationContexts[name] = context; + return context; + }; + } + + // Add allowsKey method to the schema + if (typeof schema.allowsKey !== 'function') { + schema.allowsKey = (key) => { + // For AJV schemas, check if the key exists in the properties + if (key === '_id') return true; // Always allow _id + + // Try to get the properties from the AJV schema + const definition = schema.definition || schema; + const properties = definition.properties; + + if (properties) { + return key in properties; + } + + // If we can't determine, default to allowing the key + return true; + }; + } + + // Add clean method for AJV schemas + if (typeof schema.clean !== 'function') { + schema.clean = (obj, options = {}) => { + const { mutate = false, isModifier = false } = options; + + // If not mutating, clone the object first + let cleanObj = mutate ? obj : JSON.parse(JSON.stringify(obj)); + + // For now, we'll just implement basic cleaning operations + // In a real implementation, we would do more sophisticated cleaning + + return cleanObj; + }; + + // Set default clean options + schema._cleanOptions = { + filter: true, + autoConvert: true, + removeEmptyStrings: true, + trimStrings: true + }; + } + + return schema; +}; diff --git a/package/collection2/adapters/simpleSchema.js b/package/collection2/adapters/simpleSchema.js new file mode 100644 index 0000000..af568e2 --- /dev/null +++ b/package/collection2/adapters/simpleSchema.js @@ -0,0 +1,98 @@ +import { Meteor } from 'meteor/meteor'; +import { EJSON } from 'meteor/ejson'; +import { isInsertType, isUpdateType, isUpsertType } from '../lib'; +import { isSimpleSchema } from '../schemaDetectors'; + +/** + * SimpleSchema adapter + * @param {Object} SimpleSchema - The SimpleSchema constructor + * @returns {Object} SimpleSchema adapter implementation + */ +export const createSimpleSchemaAdapter = (SimpleSchema) => ({ + name: 'SimpleSchema', + is: schema => isSimpleSchema(schema), + create: schema => new SimpleSchema(schema), + extend: (s1, s2) => { + if (s2.version >= 2) { + const ss = new SimpleSchema(s1); + ss.extend(s2); + return ss; + } else { + return new SimpleSchema([s1, s2]); + } + }, + clean: ({ doc, modifier, schema, userId, isLocalCollection, type }) => { + const isModifier = !isInsertType(type); + const target = isModifier ? modifier : doc; + schema.clean(target, { + mutate: true, + isModifier, + // We don't do these here because they are done on the client if desired + filter: false, + autoConvert: false, + removeEmptyStrings: false, + trimStrings: false, + extendAutoValueContext: { + isInsert: isInsertType(type), + isUpdate: isUpdateType(type), + isUpsert: isUpsertType(type), + userId, + isFromTrustedCode: false, + docId: doc?._id, + isLocalCollection + } + }); + }, + validate: () => {}, + getErrors: () => {}, + getErrorObject: (context, appendToMessage = '', code) => { + let message; + const invalidKeys = + typeof context.validationErrors === 'function' + ? context.validationErrors() + : context.invalidKeys(); + + if (invalidKeys?.length) { + const firstErrorKey = invalidKeys[0].name; + const firstErrorMessage = context.keyErrorMessage(firstErrorKey); + + // If the error is in a nested key, add the full key to the error message + // to be more helpful. + if (firstErrorKey.indexOf('.') === -1) { + message = firstErrorMessage; + } else { + message = `${firstErrorMessage} (${firstErrorKey})`; + } + } else { + message = 'Failed validation'; + } + message = `${message} ${appendToMessage}`.trim(); + const error = new Error(message); + error.invalidKeys = invalidKeys; + error.validationContext = context; + error.code = code; + error.name = 'ValidationError'; // Set the name to ValidationError consistently + // If on the server, we add a sanitized error, too, in case we're + // called from a method. + if (Meteor.isServer) { + error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); + } + return error; + }, + freeze: false, + + // Add validation context handling directly to the adapter + getValidationContext: (schema, validationContext) => { + if (validationContext && typeof validationContext === 'object') { + return validationContext; + } + + return schema.namedContext(validationContext); + }, + + // Enhance a SimpleSchema instance with any additional methods needed + enhance: (schema) => { + // SimpleSchema already has all the methods we need + return schema; + } +}); diff --git a/package/collection2/adapters/zod.js b/package/collection2/adapters/zod.js new file mode 100644 index 0000000..50343d7 --- /dev/null +++ b/package/collection2/adapters/zod.js @@ -0,0 +1,788 @@ +import { Meteor } from 'meteor/meteor'; +import { EJSON } from 'meteor/ejson'; +import { isInsertType } from '../lib'; +import { isZodSchema } from '../schemaDetectors'; + +/** + * Formats Zod validation errors into a consistent structure + * @param {Object} error - The Zod error object + * @returns {Array} Formatted error objects + */ +const formatZodErrors = (error) => { + if (!error || !error.issues) { + return [{ name: 'general', type: 'error', message: error?.message || 'Unknown validation error' }]; + } + + return error.issues.map(err => ({ + name: err.path.join('.'), + type: err.code, + value: err.received, + message: err.message + })); +}; + +/** + * Zod adapter + * @returns {Object} Zod adapter implementation + */ +export const createZodAdapter = (z) => ({ + name: 'zod', + is: schema => isZodSchema(schema), + create: schema => { + // If this is already a Zod schema, return it directly with namedContext + if (schema && typeof schema === 'object' && schema._def) { + // Enhance the schema with Collection2 compatibility methods + return enhanceZodSchema(schema); + } + + // For non-Zod schemas, we can't convert without the actual Zod library + throw new Error('Cannot create Zod schema from non-Zod object. Please use a Zod schema directly.'); + }, + extend: (s1, s2) => { + // For property-based detection, we need to ensure both schemas have the right properties + if (!(s1 && s1._def) || !(s2 && s2._def)) { + throw new Error('Both schemas must be Zod schemas'); + } + + // Since we don't have direct access to Zod's methods, we'll use a simplified approach + // In a real implementation, you'd merge the schemas properly + const mergedSchema = s1.merge(s2); + + // Ensure the merged schema has all the Collection2 compatibility methods + return enhanceZodSchema(mergedSchema); + }, + validate: (obj, schema, options = {}) => { + try { + // Handle modifiers for updates + if (options.modifier) { + const result = schema.partial().safeParse(obj.$set || {}); + if (result.success) { + return { isValid: true }; + } else { + return { + isValid: false, + errors: formatZodErrors(result.error) + }; + } + } else { + // Handle regular document validation + const result = schema.safeParse(obj); + if (result.success) { + return { isValid: true }; + } else { + return { + isValid: false, + errors: formatZodErrors(result.error) + }; + } + } + } catch (error) { + // Handle any unexpected errors + return { + isValid: false, + errors: formatZodErrors(error) + }; + } + }, + clean: ({ doc, modifier, schema, userId, isLocalCollection, type }) => { + // Zod schemas don't have a built-in clean method, so we use our custom implementation + const isModifier = !isInsertType(type); + const target = isModifier ? modifier : doc; + + if (typeof schema.clean === 'function') { + schema.clean(target, { + mutate: true, + isModifier + }); + } + }, + getErrorObject: (context, appendToMessage = '', code) => { + const invalidKeys = context.validationErrors(); + + if (!invalidKeys || invalidKeys.length === 0) { + const error = new Error('Unknown validation error'); + error.name = 'ValidationError'; + return error; + } + + const firstErrorKey = invalidKeys[0].name; + const firstErrorMessage = invalidKeys[0].message; + let message = firstErrorMessage; + + // Special handling for required/missing fields (invalid_type with undefined value) + if (invalidKeys[0].type === 'invalid_type' && invalidKeys[0].value === undefined) { + // Get the expected type directly from the error + const expectedType = invalidKeys[0].expected || 'string'; + message = `Field '${firstErrorKey}' is required but was not provided (expected ${expectedType})`; + } + // Special handling for other type errors + else if (invalidKeys[0].type === 'invalid_type') { + // Get the expected type directly from the error + const expectedType = invalidKeys[0].expected || 'string'; + const receivedValue = invalidKeys[0].value; + message = `Field '${firstErrorKey}' has invalid type: expected ${expectedType}, received ${receivedValue}`; + } + // Special handling for string length errors + else if (invalidKeys[0].type === 'too_small' && firstErrorKey) { + message = `Field '${firstErrorKey}' ${firstErrorMessage}`; + } + // Special handling for regex pattern errors + else if (invalidKeys[0].type === 'invalid_string' && invalidKeys[0].validation === 'regex') { + message = `Field '${firstErrorKey}' does not match required pattern`; + } + // General case with error type and value + else if (invalidKeys[0].type && invalidKeys[0].value !== undefined) { + message = `${firstErrorMessage} for field '${firstErrorKey}' (${invalidKeys[0].type}: received ${JSON.stringify(invalidKeys[0].value)})`; + } + // Fallback to standard message format + else { + if (firstErrorKey.indexOf('.') === -1) { + message = `${firstErrorMessage} for field '${firstErrorKey}'`; + } else { + message = `${firstErrorMessage} (${firstErrorKey})`; + } + } + + message = `${message} ${appendToMessage}`.trim(); + const error = new Error(message); + error.invalidKeys = invalidKeys; + error.validationContext = context; + error.code = code; + error.name = 'ValidationError'; // Set the name to ValidationError consistently + // If on the server, we add a sanitized error, too, in case we're + // called from a method. + if (Meteor.isServer) { + error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); + } + return error; + }, + getValidationContext: (schema, validationContext) => { + if (validationContext && typeof validationContext === 'object') { + return validationContext; + } + + // Ensure the schema is enhanced with Collection2 compatibility methods + const enhancedSchema = enhanceZodSchema(schema); + return enhancedSchema.namedContext(validationContext); + }, + enhance: (schema) => { + // Enhance the schema with Collection2 compatibility methods + return enhanceZodSchema(schema); + }, + freeze: false +}); + +/** + * ZodValidationContext class for handling Zod schema validation + * Provides a Collection2-compatible validation context + */ +class ZodValidationContext { + /** + * Create a new ZodValidationContext + * @param {Object} schema - The Zod schema + * @param {String} name - Optional name for the context + */ + constructor(schema, name = 'default') { + this.schema = schema; + this.name = name; + this.errors = []; + } + + /** + * Validate an object against the schema + * @param {Object} obj - The object to validate + * @param {Object} options - Validation options + * @returns {Boolean} True if valid, false otherwise + */ + validate(obj, options = {}) { + this.errors = []; // Clear previous errors + + try { + // For modifiers, we need special handling + if (options.modifier) { + // For now, we'll just validate that the fields being modified are valid + // In a real implementation, we would validate each modifier operation + const modifier = obj; + let isValid = true; + + // Check $set operations + if (modifier.$set) { + const result = this.schema.partial().safeParse(modifier.$set); + if (!this._processZodErrors(result)) { + isValid = false; + } + } + + // Check $setOnInsert operations + if (modifier.$setOnInsert) { + const result = this.schema.partial().safeParse(modifier.$setOnInsert); + if (!this._processZodErrors(result)) { + isValid = false; + } + } + + // Check $push operations + if (modifier.$push) { + // For each field in $push + Object.entries(modifier.$push).forEach(([field, value]) => { + // Get the array element schema for this field + const elementSchema = getArrayElementSchema(this.schema, field); + + + if (elementSchema) { + // Handle $each operator + if (value && typeof value === 'object' && value.$each) { + for (const item of value.$each) { + const result = elementSchema.safeParse(item); + if (!result.success) { + isValid = false; + // Add validation error for each invalid item + const err = result.error.issues[0] || {}; + const errorPath = err.path ? err.path.join('.') : ''; + const fieldName = errorPath ? `${field}.${errorPath}` : field; + + // Create a more specific error message for missing fields + let errorMessage = ''; + // Extract received value from error message since Zod v4 doesn't include it as a property + let receivedValue = err.received; + if (receivedValue === undefined && err.message) { + if (err.message.includes('received undefined')) { + receivedValue = undefined; + } else if (err.message.includes('received number')) { + receivedValue = 'number'; + } else if (err.message.includes('received string')) { + receivedValue = 'string'; + } + } + + if (err.code === 'invalid_type' && receivedValue === undefined) { + errorMessage = `Field is required but was not provided in array field '${field}'`; + } else { + errorMessage = `Invalid value for array field '${field}': ${err.message || 'validation failed'}`; + } + + this.errors.push({ + name: fieldName, + type: err.code || 'invalid_type', + value: item, + expected: err.expected || 'valid type', + message: errorMessage, + zodError: err + }); + } + } + } else { + // Handle direct value + const result = elementSchema.safeParse(value); + if (!result.success) { + isValid = false; + // Add validation error for invalid value + const err = result.error.issues[0] || {}; + const errorPath = err.path ? err.path.join('.') : ''; + const fieldName = errorPath ? `${field}.${errorPath}` : field; + + // Create a more specific error message for missing fields + let errorMessage = ''; + // Extract received value from error message since Zod v4 doesn't include it as a property + let receivedValue = err.received; + if (receivedValue === undefined && err.message) { + if (err.message.includes('received undefined')) { + receivedValue = undefined; + } else if (err.message.includes('received number')) { + receivedValue = 'number'; + } else if (err.message.includes('received string')) { + receivedValue = 'string'; + } + } + + if (err.code === 'invalid_type' && receivedValue === undefined) { + errorMessage = `Field is required but was not provided in array field '${field}'`; + } else { + errorMessage = `Invalid value for array field '${field}': ${err.message || 'validation failed'}`; + } + + this.errors.push({ + name: fieldName, + type: 'ValidationError', + value: value, + expected: err.expected || 'valid type', + message: errorMessage + }); + } + } + } else { + // If we can't find a schema for this field, check if the field is allowed + if (!this.schema.allowsKey(field)) { + this.errors.push({ + name: field, + type: 'invalid_key', + value: value, + message: `Field '${field}' is not allowed by the schema` + }); + isValid = false; + } else { + // Field is allowed but we couldn't extract array element schema + // This might indicate an issue with the schema structure + this.errors.push({ + name: field, + type: 'schema_error', + value: value, + message: `Cannot validate array field '${field}': unable to extract element schema` + }); + isValid = false; + } + } + }); + } + + // Check $addToSet operations + if (modifier.$addToSet) { + // For each field in $addToSet + Object.entries(modifier.$addToSet).forEach(([field, value]) => { + // Get the array element schema for this field + const elementSchema = getArrayElementSchema(this.schema, field); + + if (elementSchema) { + // Handle $each operator + if (value && typeof value === 'object' && value.$each) { + for (const item of value.$each) { + const result = elementSchema.safeParse(item); + if (!result.success) { + isValid = false; + // Add validation error for each invalid item + const err = result.error.issues[0] || {}; + const errorPath = err.path ? err.path.join('.') : ''; + const fieldName = errorPath ? `${field}.${errorPath}` : field; + + // Create a more specific error message for missing fields + let errorMessage = ''; + // Extract received value from error message since Zod v4 doesn't include it as a property + let receivedValue = err.received; + if (receivedValue === undefined && err.message) { + if (err.message.includes('received undefined')) { + receivedValue = undefined; + } else if (err.message.includes('received number')) { + receivedValue = 'number'; + } else if (err.message.includes('received string')) { + receivedValue = 'string'; + } + } + + if (err.code === 'invalid_type' && receivedValue === undefined) { + errorMessage = `Field is required but was not provided in array field '${field}'`; + } else { + errorMessage = `Invalid value for array field '${field}': ${err.message || 'validation failed'}`; + } + + this.errors.push({ + name: fieldName, + type: err.code || 'invalid_type', + value: item, + expected: err.expected || 'valid type', + message: errorMessage, + zodError: err + }); + } + } + } else { + // Handle direct value + const result = elementSchema.safeParse(value); + if (!result.success) { + isValid = false; + // Add validation error for invalid value + const err = result.error.issues[0] || {}; + const errorPath = err.path ? err.path.join('.') : ''; + const fieldName = errorPath ? `${field}.${errorPath}` : field; + + // Create a more specific error message for missing fields + let errorMessage = ''; + // Extract received value from error message since Zod v4 doesn't include it as a property + let receivedValue = err.received; + if (receivedValue === undefined && err.message) { + if (err.message.includes('received undefined')) { + receivedValue = undefined; + } else if (err.message.includes('received number')) { + receivedValue = 'number'; + } else if (err.message.includes('received string')) { + receivedValue = 'string'; + } + } + + if (err.code === 'invalid_type' && receivedValue === undefined) { + errorMessage = `Field is required but was not provided in array field '${field}'`; + } else { + errorMessage = `Invalid value for array field '${field}': ${err.message || 'validation failed'}`; + } + + this.errors.push({ + name: fieldName, + type: 'ValidationError', + value: value, + expected: err.expected || 'valid type', + message: errorMessage + }); + } + } + } else { + // If we can't find a schema for this field, check if the field is allowed + if (!this.schema.allowsKey(field)) { + this.errors.push({ + name: field, + type: 'invalid_key', + value: value, + message: `Field '${field}' is not allowed by the schema` + }); + isValid = false; + } else { + // Field is allowed but we couldn't extract array element schema + // This might indicate an issue with the schema structure + this.errors.push({ + name: field, + type: 'schema_error', + value: value, + message: `Cannot validate array field '${field}': unable to extract element schema` + }); + isValid = false; + } + } + }); + } + + return isValid; + } else { + // For normal documents (insert) + const result = this.schema.safeParse(obj); + if (result.success) { + return true; + } else { + this._processZodErrors(result); + return false; + } + } + } catch (error) { + this.errors.push({ + name: 'general', + type: 'validation_error', + value: null, + message: error.message || 'Validation failed' + }); + return false; + } + } + + /** + * Process Zod validation errors + * @private + * @param {Object} result - The Zod validation result + * @param {Boolean} isArray - Whether the validation is for an array + * @returns {Boolean} True if valid, false otherwise + */ + _processZodErrors(result, isArray = false) { + if (!result.success) { + // Set the error name to ValidationError for consistent error handling + if (result.error) { + result.error.name = 'ValidationError'; + } + + const zodErrors = result.error.issues || []; + for (const err of zodErrors) { + const path = isArray ? (Array.isArray(err.path) ? err.path.join('.') : err.path) : err.path.join('.'); + + // Extract expected type from Zod error + let expectedType = 'valid type'; + if (err.code === 'invalid_type') { + // For type errors, Zod provides the expected type + expectedType = err.expected; + } else if (err.code === 'too_small' || err.code === 'too_big') { + // For string length errors + expectedType = 'string'; + } else if (err.code === 'invalid_string') { + // For string validation errors (regex, email, etc.) + expectedType = `string (${err.validation})`; + } + + // Extract received value from error message since Zod v4 doesn't include it as a property + let receivedValue = err.received; + if (receivedValue === undefined && err.message) { + if (err.message.includes('received undefined')) { + receivedValue = undefined; + } else if (err.message.includes('received number')) { + receivedValue = 'number'; + } else if (err.message.includes('received string')) { + receivedValue = 'string'; + } else if (err.message.includes('received object')) { + receivedValue = 'object'; + } else if (err.message.includes('received array')) { + receivedValue = 'array'; + } else if (err.message.includes('received boolean')) { + receivedValue = 'boolean'; + } + } + + this.errors.push({ + name: path || 'general', + type: err.code, // Use the original Zod error code instead of hardcoding 'ValidationError' + value: receivedValue, + expected: expectedType, + message: err.message, + zodError: err + }); + } + return false; + } + return true; + } + + /** + * Get all validation errors + * @returns {Array} Array of validation errors + */ + validationErrors() { + return this.errors; + } + + /** + * Get all invalid keys (alias for validationErrors) + * @returns {Array} Array of validation errors + */ + invalidKeys() { + return this.errors; + } + + /** + * Reset validation state + */ + resetValidation() { + this.errors = []; + } + + /** + * Check if the validation is valid + * @returns {Boolean} True if valid, false otherwise + */ + isValid() { + return this.errors.length === 0; + } + + /** + * Check if a specific key is invalid + * @param {String} key - The key to check + * @returns {Boolean} True if the key is invalid, false otherwise + */ + keyIsInvalid(key) { + return this.errors.some(err => err.name === key); + } + + /** + * Get the error message for a specific key + * @param {String} key - The key to get the error message for + * @returns {String} The error message or empty string if no error + */ + keyErrorMessage(key) { + const error = this.errors.find(err => err.name === key); + return error ? error.message : ''; + } +} + +/** + * Creates a validation context for Zod schemas + * @param {Object} schema - The Zod schema + * @param {String} name - Optional name for the context + * @returns {Object} A validation context compatible with Collection2 + */ +const createZodValidationContext = (schema, name = 'default') => { + return new ZodValidationContext(schema, name); +}; + +/** + * Extracts the array element schema from a Zod schema for a specific field path + * @param {Object} schema - The Zod schema + * @param {String} fieldPath - The field path (e.g., 'comments' or 'nested.array') + * @returns {Object|null} The array element schema or null if not found/not an array + */ +const getArrayElementSchema = (schema, fieldPath) => { + try { + // Get the shape from the schema definition + // In Zod v4, shape is a property, not a function + const shape = schema._def?.shape; + if (!shape) return null; + + // Handle nested paths (e.g., 'nested.array') + const parts = fieldPath.split('.'); + let currentShape = shape; + let currentSchema = null; + + // Navigate through the nested structure + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!currentShape[part]) return null; + + currentSchema = currentShape[part]; + + // If we're not at the last part, we need to get the nested shape + if (i < parts.length - 1) { + // For objects, get the nested shape + if (currentSchema._def?.type === 'object') { + // In Zod v4, shape is a property, not a function + currentShape = currentSchema._def.shape; + } else { + // Not an object, can't navigate further + return null; + } + } + } + + // Check if the field is an optional type + if (currentSchema._def?.type === 'optional') { + // Unwrap the optional type + currentSchema = currentSchema._def.innerType; + } + + // Check if the field is an array + if (currentSchema._def?.type === 'array') { + // In Zod v4, array element schema is at .def.element + return currentSchema.def?.element || currentSchema._def?.element; + } + + return null; // Not an array + } catch (error) { + console.error('Error extracting array element schema:', error); + return null; + } +}; + +/** + * Enhances a Zod schema with Collection2 compatibility methods + * @param {Object} schema - The Zod schema to enhance + * @returns {Object} The enhanced schema + */ +const enhanceZodSchema = (schema) => { + // Store validation contexts by name + const validationContexts = {}; + + // Add namedContext method if it doesn't exist + if (typeof schema.namedContext !== 'function') { + schema.namedContext = function(name = 'default') { + // Reuse existing context if available + if (validationContexts[name]) { + return validationContexts[name]; + } + + // Create and store a new context + const context = createZodValidationContext(schema, name); + validationContexts[name] = context; + return context; + }; + } + + // Add allowsKey method to the schema + if (typeof schema.allowsKey !== 'function') { + schema.allowsKey = (key) => { + // For Zod schemas, check if the key exists in the shape + if (key === '_id') return true; // Always allow _id + + // Try to get the shape from the Zod schema + // In Zod v4, shape is a property, not a function + const shape = schema._def?.shape; + if (shape) { + // If unknownKeys is set to 'ignore' or 'passthrough', allow any key + if (schema._def.unknownKeys === 'ignore' || schema._def.unknownKeys === 'passthrough') { + return true; + } + + return key in shape; + } + + // If we can't determine, default to allowing the key + return true; + }; + } + + // Add clean method for Zod schemas + if (typeof schema.clean !== 'function') { + schema.clean = (obj, options = {}) => { + const { mutate = false, isModifier = false } = options; + + // If not mutating, clone the object first + let cleanObj = mutate ? obj : JSON.parse(JSON.stringify(obj)); + + if (isModifier) { + // For update operations with modifiers like $set, $unset, etc. + if (cleanObj.$set) { + // Process each field in $set + Object.keys(cleanObj.$set).forEach(key => { + const value = cleanObj.$set[key]; + + // Remove empty strings if option is enabled + if (options.removeEmptyStrings && value === '') { + delete cleanObj.$set[key]; + } + + // Auto-convert strings to numbers/booleans/etc if option is enabled + if (options.autoConvert) { + // Would need more complex logic to properly convert types + // For now, we just do a basic conversion for common types + if (typeof value === 'string') { + if (value === 'true') cleanObj.$set[key] = true; + else if (value === 'false') cleanObj.$set[key] = false; + else if (!isNaN(value) && value.trim() !== '') cleanObj.$set[key] = Number(value); + } + } + + // Trim strings if option is enabled + if (options.trimStrings && typeof value === 'string') { + cleanObj.$set[key] = value.trim(); + } + }); + + // Remove $set if it's empty after processing + if (Object.keys(cleanObj.$set).length === 0) { + delete cleanObj.$set; + } + } + + // Process other modifiers if needed + } else { + // For insert/update operations without modifiers + + // Process each field in the document + Object.keys(cleanObj).forEach(key => { + const value = cleanObj[key]; + + // Remove empty strings if option is enabled + if (options.removeEmptyStrings && value === '') { + delete cleanObj[key]; + } + + // Auto-convert strings to numbers/booleans/etc if option is enabled + if (options.autoConvert) { + // Would need more complex logic to properly convert types + // For now, we just do a basic conversion for common types + if (typeof value === 'string') { + if (value === 'true') cleanObj[key] = true; + else if (value === 'false') cleanObj[key] = false; + else if (!isNaN(value) && value.trim() !== '') cleanObj[key] = Number(value); + } + } + + // Trim strings if option is enabled + if (options.trimStrings && typeof value === 'string') { + cleanObj[key] = value.trim(); + } + }); + } + + return cleanObj; + }; + + // Set default clean options + schema._cleanOptions = { + filter: true, + autoConvert: true, + removeEmptyStrings: true, + trimStrings: true + }; + } + + return schema; +}; diff --git a/package/collection2/main.js b/package/collection2/main.js index db18658..075c605 100644 --- a/package/collection2/main.js +++ b/package/collection2/main.js @@ -3,10 +3,115 @@ import { Mongo } from 'meteor/mongo'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { EJSON } from 'meteor/ejson'; import { flattenSelector, isInsertType, isUpdateType, isUpsertType, isObject, isEqual } from './lib'; +import { detectSchemaType } from './schemaDetectors'; +import { createSimpleSchemaAdapter as simpleSchemaAdapter } from './adapters/simpleSchema'; +import { createZodAdapter as zodAdapter } from './adapters/zod'; +import { createAjvAdapter as ajvAdapter } from './adapters/ajv'; const meteorVersion = Meteor.release === 'none' ? [3, 1] : Meteor.release.split('@')[1].split('.'); const noAsyncAllow = meteorVersion[0] >= 3 && meteorVersion[1].split('-')[0] >= 1; +const C2 = {}; +C2._validators = {}; + +C2.init = self => { + self._c2 = self._c2 || Object.create(null); + self._c2.schemas = self._c2.schemas || [null]; + return self; +}; + +C2.validator = () => { + // Get the appropriate validator based on the schema type + return C2._getValidator(); +} + +// Helper function to detect schema type and return the appropriate validator +C2._getValidator = () => { + // If we've already determined a validator, return it + if (C2._currentValidator) { + return C2._currentValidator; + } + + // Try to load validators for known schema libraries + try { + // Check for SimpleSchema + if (typeof SimpleSchema !== 'undefined') { + C2._validators.SimpleSchema = C2._validators.SimpleSchema || simpleSchemaAdapter(SimpleSchema); + // No need to attach anything to the SimpleSchema library anymore + } + } catch (e) { + console.error('Error loading schema validators:', e); + } + + return null; +} + +/** + * @private + * @param {SimpleSchema|Object} schema + * @returns {Object} Schema validator + */ +C2._detectSchemaType = function(schema) { + // Use the centralized schema detection functions + const schemaType = detectSchemaType(schema); + + switch (schemaType) { + case 'SimpleSchema': + if (!C2._validators.SimpleSchema) { + C2._validators.SimpleSchema = simpleSchemaAdapter(SimpleSchema); + } + C2._currentValidator = C2._validators.SimpleSchema; + return C2._validators.SimpleSchema; + + case 'zod': + if (!C2._validators.zod) { + C2._validators.zod = zodAdapter({ + ZodType: function() {}, // Dummy constructor for instanceof checks + object: (obj) => obj // Simplified for detection purposes + }); + } + + // Ensure the schema has necessary methods by enhancing it + if (C2._validators.zod.enhance && typeof C2._validators.zod.enhance === 'function') { + schema = C2._validators.zod.enhance(schema); + } + + C2._currentValidator = C2._validators.zod; + return C2._validators.zod; + + case 'ajv': + if (!C2._validators.ajv) { + C2._validators.ajv = ajvAdapter(function() {}); // Dummy constructor + } + + // Ensure the schema has necessary methods by enhancing it + if (C2._validators.ajv.enhance && typeof C2._validators.ajv.enhance === 'function') { + schema = C2._validators.ajv.enhance(schema); + } + + C2._currentValidator = C2._validators.ajv; + return C2._validators.ajv; + + default: + // If we can't detect the schema type, default to SimpleSchema if available + if (C2._validators.SimpleSchema) { + C2._currentValidator = C2._validators.SimpleSchema; + return C2._validators.SimpleSchema; + } + + throw new Error(`Cannot determine schema type. Make sure you have a supported schema library installed (SimpleSchema, Zod, or AJV).`); + } +}; + +C2.schemas = (self) => { + if (!self._c2) { + C2.init(self); + } + return self._c2.schemas; +} + +Object.assign(Collection2, { isInsertType, isUpsertType, isUpdateType }) + /** * Mongo.Collection.prototype.attachSchema * @param {SimpleSchema|Object} ss - SimpleSchema instance or a schema definition object @@ -24,52 +129,60 @@ const noAsyncAllow = meteorVersion[0] >= 3 && meteorVersion[1].split('-')[0] >= * schema object passed to its constructor. */ Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { - options = options || {}; + options = options || Object.create(null); + + const self = this; + + // Detect schema type and get appropriate validator + const validator = C2._detectSchemaType(ss); // Allow passing just the schema object - if (!SimpleSchema.isSimpleSchema(ss)) { - ss = new SimpleSchema(ss); + if (!validator.is(ss)) { + ss = validator.create(ss); } function attachTo(obj) { // we need an array to hold multiple schemas // position 0 is reserved for the "base" schema - obj._c2 = obj._c2 || {}; - obj._c2._simpleSchemas = obj._c2._simpleSchemas || [null]; + C2.init(obj); + + const allSchemas = C2.schemas(obj); if (typeof options.selector === 'object') { // Selector Schemas // Extend selector schema with base schema - const baseSchema = obj._c2._simpleSchemas[0]; - if (baseSchema) { - ss = extendSchema(baseSchema.schema, ss); + const base = allSchemas[0]; + if (base) { + ss = validator.extend(base.schema, ss); } // Index of existing schema with identical selector - let schemaIndex; - + let index; + // Loop through existing schemas with selectors, - for (schemaIndex = obj._c2._simpleSchemas.length - 1; schemaIndex > 0; schemaIndex--) { - const schema = obj._c2._simpleSchemas[schemaIndex]; - if (schema && isEqual(schema.selector, options.selector)) break; + for (index = allSchemas.length - 1; index > 0; index--) { + const current = allSchemas[index]; + if (current && isEqual(current.selector, options.selector)) break; } - - if (schemaIndex <= 0) { + + if (index <= 0) { // We didn't find the schema in our array - push it into the array - obj._c2._simpleSchemas.push({ + allSchemas.push({ schema: ss, selector: options.selector }); - } else { + } + else { // We found a schema with an identical selector in our array, if (options.replace === true) { // Replace existing selector schema with new selector schema - obj._c2._simpleSchemas[schemaIndex].schema = ss; - } else { + allSchemas[index].schema = ss; + } + else { // Extend existing selector schema with new selector schema. - obj._c2._simpleSchemas[schemaIndex].schema = extendSchema( - obj._c2._simpleSchemas[schemaIndex].schema, + allSchemas[index].schema = validator.extend( + allSchemas[index].schema, ss ); } @@ -78,23 +191,21 @@ Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { // Base Schema if (options.replace === true) { // Replace base schema and delete all other schemas - obj._c2._simpleSchemas = [ - { - schema: ss, - selector: options.selector - } - ]; + obj._c2.schemas = [{ + schema: ss, + selector: options.selector + }]; } else { // Set base schema if not yet set - if (!obj._c2._simpleSchemas[0]) { - obj._c2._simpleSchemas[0] = { schema: ss, selector: undefined }; - return obj._c2._simpleSchemas[0]; + if (!allSchemas[0]) { + allSchemas[0] = { schema: ss, selector: undefined }; + return allSchemas[0]; } // Extend base schema and therefore extend all schemas - obj._c2._simpleSchemas.forEach((schema, index) => { - if (obj._c2._simpleSchemas[index]) { - obj._c2._simpleSchemas[index].schema = extendSchema( - obj._c2._simpleSchemas[index].schema, + allSchemas.forEach((schema, i) => { + if (allSchemas[i]) { + allSchemas[i].schema = validator.extend( + allSchemas[i].schema, ss ); } @@ -102,18 +213,18 @@ Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { } } } - - attachTo(this); + + attachTo(self); // Attach the schema to the underlying LocalCollection, too - if (this._collection instanceof LocalCollection) { - this._collection._c2 = this._collection._c2 || {}; - attachTo(this._collection); + if (self._collection instanceof LocalCollection) { + C2.init(self._collection); + attachTo(self._collection); } - - defineDeny(this, options); - keepInsecure(this); - - Collection2.emit('schema.attached', this, ss, options); + + defineDeny(self, options); + keepInsecure(self); + + Collection2.emit('schema.attached', self, ss, options); }; for (const obj of [Mongo.Collection, LocalCollection]) { @@ -128,16 +239,18 @@ Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { * @param {Object} [query] - it could be on update/upsert * @return {Object} Schema */ - obj.prototype.simpleSchema = function (doc, options, query) { - if (!this._c2) return null; - if (this._c2._simpleSchema) return this._c2._simpleSchema; + obj.prototype.c2Schema = function (doc, options, query) { + const self = this; + if (!self._c2) return null; + if (self._c2._schema) return self._c2._schema; - const schemas = this._c2._simpleSchemas; - if (schemas && schemas.length > 0) { + const allSchemas = C2.schemas(self); + + if (allSchemas && allSchemas.length > 0) { let schema, selector, target; // Position 0 reserved for base schema - for (let i = 1; i < schemas.length; i++) { - schema = schemas[i]; + for (let i = 1; i < allSchemas.length; i++) { + schema = allSchemas[i]; selector = Object.keys(schema.selector)[0]; // We will set this to undefined because in theory, you might want to select @@ -162,8 +275,8 @@ Mongo.Collection.prototype.attachSchema = function c2AttachSchema(ss, options) { return schema.schema; } } - if (schemas[0]) { - return schemas[0].schema; + if (allSchemas[0]) { + return allSchemas[0].schema; } else { throw new Error('No default schema'); } @@ -190,15 +303,15 @@ function getArgumentsAndValidationContext(methodName, args, async) { userId = Meteor.userId(); } catch (err) {} - [validatedArgs, validationContext] = doValidate( - this, - methodName, + [validatedArgs, validationContext] = doValidate({ + collection: this, + type: methodName, args, - Meteor.isServer || this._connection === null, // getAutoValues + getAutoValues: Meteor.isServer || this._connection === null, // getAutoValues userId, - Meteor.isServer, // isFromTrustedCode + isFromTrustedCode: Meteor.isServer, // isFromTrustedCode async - ); + }); if (!validatedArgs) { // doValidate already called the callback or threw the error, so we're done. @@ -219,35 +332,65 @@ function getArgumentsAndValidationContext(methodName, args, async) { : Mongo.Collection.prototype[methodName.replace('Async', '')]; if (!_super) return; + Mongo.Collection.prototype[methodName] = function (...args) { - const [validatedArgs, validationContext] = getArgumentsAndValidationContext.call(this, methodName, args, async); - - if (async && !Meteor.isFibersDisabled) { - try { - this[methodName.replace('Async', '')].isCalledFromAsync = true; - _super.isCalledFromAsync = true; - return Promise.resolve(_super.apply(this, validatedArgs)); - } catch (err) { - if (this._c2) { - const addValidationErrorsPropName = + let options = isInsertType(methodName) ? args[1] : args[2]; + + // Support missing options arg + if (!options || typeof options === 'function') { + options = {}; + } + + let validationContext = {}; + let error; + if (this._c2 && options.bypassCollection2 !== true) { + let userId = null; + try { + // https://github.com/aldeed/meteor-collection2/issues/175 + userId = Meteor.userId(); + } catch (err) {} + + [args, validationContext] = doValidate({ + collection: this, + type: methodName, + args, + getAutoValues: Meteor.isServer || this._connection === null, // getAutoValues + userId, + isFromTrustedCode: Meteor.isServer, // isFromTrustedCode + async + }); + + if (!args) { + // doValidate already called the callback or threw the error, so we're done. + // But insert should always return an ID to match core behavior. + return isInsertType(methodName) ? this._makeNewID() : undefined; + } + } else { + // We still need to adjust args because insert does not take options + if (isInsertType(methodName) && typeof args[1] !== 'function') args.splice(1, 1); + } + + if (async && !Meteor.isFibersDisabled) { + try { + this[methodName.replace('Async', '')].isCalledFromAsync = true; + _super.isCalledFromAsync = true; + return Promise.resolve(_super.apply(this, args)); + } catch (err) { + const addValidationErrorsPropName = typeof validationContext.addValidationErrors === 'function' - ? 'addValidationErrors' - : 'addInvalidKeys'; - parsingServerError([err], validationContext, addValidationErrorsPropName); - const error = getErrorObject(validationContext, err.message, err.code); - return Promise.reject(error); - } else { - // do not change error if collection isn't being validated by collection2 - return Promise.reject(err); - } - } - } else { - return _super.apply(this, validatedArgs); - } + ? 'addValidationErrors' + : 'addInvalidKeys'; + parsingServerError([err], validationContext, addValidationErrorsPropName); + error = getErrorObject(validationContext, err.message, err.code); + return Promise.reject(error); + } + } else { + return _super.apply(this, args); + } }; - } - - function _methodMutationAsync(methodName) { + } + + function _methodMutationAsync(methodName) { const _super = Mongo.Collection.prototype[methodName]; Mongo.Collection.prototype[methodName] = async function (...args) { const [validatedArgs, validationContext] = getArgumentsAndValidationContext.call(this, methodName, args, true); @@ -272,20 +415,20 @@ function getArgumentsAndValidationContext(methodName, args, async) { // Wrap DB write operation methods - if (Mongo.Collection.prototype.insertAsync) { - if (Meteor.isFibersDisabled) { - ['insertAsync', 'updateAsync'].forEach(_methodMutationAsync.bind(this)); - } else { - ['insertAsync', 'updateAsync'].forEach(_methodMutation.bind(this, true)); - } +if (Mongo.Collection.prototype.insertAsync) { + if (Meteor.isFibersDisabled) { + ['insertAsync', 'updateAsync'].forEach(_methodMutationAsync.bind(this)); + } else { + ['insertAsync', 'updateAsync'].forEach(_methodMutation.bind(this, true)); } ['insert', 'update'].forEach(_methodMutation.bind(this, false)); +} - /* - * Private - */ - - function doValidate(collection, type, args, getAutoValues, userId, isFromTrustedCode, async) { +/* + * Private + */ + +function doValidate({ collection, type, args = [], getAutoValues, userId, isFromTrustedCode, async }) { let doc, callback, error, options, selector; if (!args.length) { @@ -314,15 +457,15 @@ function getArgumentsAndValidationContext(methodName, args, async) { } else { throw new Error('invalid type argument'); } - + const validatedObjectWasInitiallyEmpty = Object.keys(doc).length === 0; - + // Support missing options arg if (!callback && typeof options === 'function') { callback = options; options = {}; } - options = options || {}; + options = options || Object.create(null); const last = args.length - 1; @@ -333,7 +476,7 @@ function getArgumentsAndValidationContext(methodName, args, async) { // we need to pass `doc` and `options` to `simpleSchema` method, that's why // schema declaration moved here - let schema = collection.simpleSchema(doc, options, selector); + let schema = collection.c2Schema(doc, options, selector); const isLocalCollection = collection._connection === null; // On the server and for local collections, we allow passing `getAutoValues: false` to disable autoValue functions @@ -361,6 +504,7 @@ function getArgumentsAndValidationContext(methodName, args, async) { validationContext = schema.namedContext(validationContext); } } else { + // For backward compatibility, check if schema has namedContext method validationContext = schema.namedContext(); } @@ -556,37 +700,18 @@ function getArgumentsAndValidationContext(methodName, args, async) { } function getErrorObject(context, appendToMessage = '', code) { - let message; - const invalidKeys = - typeof context.validationErrors === 'function' - ? context.validationErrors() - : context.invalidKeys?.(); - - if (invalidKeys?.length) { - const firstErrorKey = invalidKeys[0].name; - const firstErrorMessage = context.keyErrorMessage(firstErrorKey); - - // If the error is in a nested key, add the full key to the error message - // to be more helpful. - if (firstErrorKey.indexOf('.') === -1) { - message = firstErrorMessage; - } else { - message = `${firstErrorMessage} (${firstErrorKey})`; - } - } else { - message = 'Failed validation'; + // Get the current validator from Collection2 + const validator = C2._currentValidator; + + // If the validator has a getErrorObject method, use it + if (validator && typeof validator.getErrorObject === 'function') { + return validator.getErrorObject(context, appendToMessage, code); } - message = `${message} ${appendToMessage}`.trim(); - const error = new Error(message); - error.invalidKeys = invalidKeys; - error.validationContext = context; - error.code = code; - // If on the server, we add a sanitized error, too, in case we're - // called from a method. - if (Meteor.isServer) { - error.sanitizedError = new Meteor.Error(400, message, EJSON.stringify(error.invalidKeys)); - } - return error; + + // If we get here, it means we couldn't find a validator or the validator doesn't have a getErrorObject method + // This indicates a problem with schema detection or adapter implementation + throw new Error('No validator found or validator does not implement getErrorObject method. ' + + 'This indicates a problem with schema detection or adapter implementation.'); } function addUniqueError(context, errorMessage) { @@ -694,66 +819,35 @@ function getArgumentsAndValidationContext(methodName, args, async) { // own for each operation for this collection. And the user may still add // additional deny functions, but does not have to. } - - const alreadyDefined = {}; - - function defineDeny(c, options) { - if (!alreadyDefined[c._name]) { - const isLocalCollection = c._connection === null; - - // First, define deny functions to extend doc with the results of clean - // and auto-values. This must be done with "transform: null" or we would be - // extending a clone of doc and therefore have no effect. - const firstDeny = { - insert: function (userId, doc) { - // Referenced doc is cleaned in place - c.simpleSchema(doc).clean(doc, { - mutate: true, - isModifier: false, - // We don't do these here because they are done on the client if desired - filter: false, - autoConvert: false, - removeEmptyStrings: false, - trimStrings: false, - extendAutoValueContext: { - isInsert: true, - isUpdate: false, - isUpsert: false, - userId, - isFromTrustedCode: false, - docId: doc._id, - isLocalCollection - } - }); - - return false; - }, - update: function (userId, doc, fields, modifier) { - // Referenced modifier is cleaned in place - c.simpleSchema(modifier).clean(modifier, { - mutate: true, - isModifier: true, - // We don't do these here because they are done on the client if desired - filter: false, - autoConvert: false, - removeEmptyStrings: false, - trimStrings: false, - extendAutoValueContext: { - isInsert: false, - isUpdate: true, - isUpsert: false, - userId, - isFromTrustedCode: false, - docId: doc && doc._id, - isLocalCollection - } - }); - - return false; - }, - fetch: ['_id'], - transform: null - }; + +C2.alreadyDefined = {}; + +function defineDeny(collection, options) { + const validator = C2.validator(); + if (C2.alreadyDefined[collection._name]) { + return false; // no definition added; + } + const isLocalCollection = collection._connection === null; + + // First, define deny functions to extend doc with the results of clean + // and auto-values. This must be done with "transform: null" or we would be + // extending a clone of doc and therefore have no effect. + const firstDeny = { + insert: function (userId, doc) { + // Referenced doc is cleaned in place + const schema = collection.c2Schema(doc); + validator.clean({ doc, schema, userId, isLocalCollection, type: 'insert' }); + return false; + }, + update: function (userId, doc, fields, modifier) { + // Referenced modifier is cleaned in place + const schema = collection.c2Schema(doc); + validator.clean({ userId, doc, fields, modifier, schema, type: 'update' }); + return false; + }, + fetch: ['_id'], + transform: null + }; if (Meteor.isFibersDisabled && !noAsyncAllow) { Object.assign(firstDeny, { @@ -762,73 +856,73 @@ function getArgumentsAndValidationContext(methodName, args, async) { }); } - c.deny(firstDeny); - - // Second, define deny functions to validate again on the server - // for client-initiated inserts and updates. These should be - // called after the clean/auto-value functions since we're adding - // them after. These must *not* have "transform: null" if options.transform is true because - // we need to pass the doc through any transforms to be sure - // that custom types are properly recognized for type validation. - const secondDeny = { - insert: function (userId, doc) { - // We pass the false options because we will have done them on the client if desired - doValidate( - c, - 'insert', - [ - doc, - { - trimStrings: false, - removeEmptyStrings: false, - filter: false, - autoConvert: false - }, - function (error) { - if (error) { - throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); - } - } - ], - false, // getAutoValues - userId, - false // isFromTrustedCode - ); - - return false; - }, - update: function (userId, doc, fields, modifier) { - // NOTE: This will never be an upsert because client-side upserts - // are not allowed once you define allow/deny functions. - // We pass the false options because we will have done them on the client if desired - doValidate( - c, - 'update', - [ - { _id: doc && doc._id }, - modifier, - { - trimStrings: false, - removeEmptyStrings: false, - filter: false, - autoConvert: false - }, - function (error) { - if (error) { - throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); - } - } - ], - false, // getAutoValues - userId, - false // isFromTrustedCode - ); - - return false; - }, - fetch: ['_id'], - ...(options.transform === true ? {} : { transform: null }) - }; + collection.deny(firstDeny); + + // Second, define deny functions to validate again on the server + // for client-initiated inserts and updates. These should be + // called after the clean/auto-value functions since we're adding + // them after. These must *not* have "transform: null" if options.transform is true because + // we need to pass the doc through any transforms to be sure + // that custom types are properly recognized for type validation. + const secondDeny = { + insert: function (userId, doc) { + // We pass the false options because we will have done them on the client if desired + doValidate({ + collection, + type: 'insert', + args: [ + doc, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function (error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + getAutoValues: false, // getAutoValues + userId, + isFromTrustedCode: false // isFromTrustedCode + }); + + return false; + }, + update: function (userId, doc, fields, modifier) { + // NOTE: This will never be an upsert because client-side upserts + // are not allowed once you define allow/deny functions. + // We pass the false options because we will have done them on the client if desired + doValidate({ + collection, + type: 'update', + args: [ + { _id: doc && doc._id }, + modifier, + { + trimStrings: false, + removeEmptyStrings: false, + filter: false, + autoConvert: false + }, + function (error) { + if (error) { + throw new Meteor.Error(400, 'INVALID', EJSON.stringify(error.invalidKeys)); + } + } + ], + getAutoValues: false, // getAutoValues + userId, + isFromTrustedCode: false // isFromTrustedCode + }); + + return false; + }, + fetch: ['_id'], + ...(options.transform === true ? {} : { transform: null }) + }; if (Meteor.isFibersDisabled && !noAsyncAllow) { Object.assign(secondDeny, { @@ -837,20 +931,10 @@ function getArgumentsAndValidationContext(methodName, args, async) { }); } - c.deny(secondDeny); + collection.deny(secondDeny); - // note that we've already done this collection so that we don't do it again - // if attachSchema is called again - alreadyDefined[c._name] = true; - } - } - - function extendSchema(s1, s2) { - if (s2.version >= 2) { - const ss = new SimpleSchema(s1); - ss.extend(s2); - return ss; - } else { - return new SimpleSchema([s1, s2]); - } - } + // note that we've already done this collection so that we don't do it again + // if attachSchema is called again + C2.alreadyDefined[collection._name] = true; + return true; // new definition added +} \ No newline at end of file diff --git a/package/collection2/package.js b/package/collection2/package.js index 75b933a..a6b861d 100644 --- a/package/collection2/package.js +++ b/package/collection2/package.js @@ -1,10 +1,9 @@ - /* global Package, Npm */ Package.describe({ name: 'aldeed:collection2', summary: 'Automatic validation of Meteor Mongo insert and update operations on the client and server', - version: '4.1.4', + version: '4.2.0-beta.1', documentation: '../../README.md', git: 'https://github.com/aldeed/meteor-collection2.git' }); diff --git a/package/collection2/schemaDetectors.js b/package/collection2/schemaDetectors.js new file mode 100644 index 0000000..86e9009 --- /dev/null +++ b/package/collection2/schemaDetectors.js @@ -0,0 +1,78 @@ +import { Meteor } from 'meteor/meteor'; + +/** + * Schema detectors for different validation libraries + * These functions provide a consistent way to detect schema types + * across the Collection2 package + */ + +/** + * Determines if a schema is a SimpleSchema schema + * @param {Object} schema - The schema to check + * @returns {Boolean} True if the schema is a SimpleSchema schema + */ +export const isSimpleSchema = (schema) => { + // Check if SimpleSchema.isSimpleSchema exists and use it + if (typeof SimpleSchema !== 'undefined' && typeof SimpleSchema.isSimpleSchema === 'function') { + return SimpleSchema.isSimpleSchema(schema); + } + + // Fallback to property-based detection + return schema && + typeof schema === 'object' && + typeof schema.schema === 'function' && + typeof schema.validator === 'function' && + typeof schema.clean === 'function' && + typeof schema.namedContext === 'function'; +}; + +/** + * Determines if a schema is a Zod schema + * @param {Object} schema - The schema to check + * @returns {Boolean} True if the schema is a Zod schema + */ +export const isZodSchema = (schema) => { + return schema && + typeof schema === 'object' && + schema._def && + schema.safeParse && + schema.parse && + typeof schema.safeParse === 'function' && + typeof schema.parse === 'function'; +}; + +/** + * Determines if a schema is an AJV schema + * @param {Object} schema - The schema to check + * @returns {Boolean} True if the schema is an AJV schema + */ +export const isAjvSchema = (schema) => { + return schema && + typeof schema === 'object' && + ((schema.compile && schema.validate && + typeof schema.compile === 'function' && + typeof schema.validate === 'function') || + (schema.type === 'object' && schema.properties && + typeof schema.properties === 'object')); +}; + +/** + * Detects the type of schema + * @param {Object} schema - The schema to check + * @returns {String} The type of schema ('SimpleSchema', 'zod', 'ajv', or 'unknown') + */ +export const detectSchemaType = (schema) => { + if (isSimpleSchema(schema)) { + return 'SimpleSchema'; + } + + if (isZodSchema(schema)) { + return 'zod'; + } + + if (isAjvSchema(schema)) { + return 'ajv'; + } + + return 'unknown'; +}; diff --git a/tests/.meteor/packages b/tests/.meteor/packages index 9911a6e..1486903 100644 --- a/tests/.meteor/packages +++ b/tests/.meteor/packages @@ -24,5 +24,4 @@ dynamic-import@0.7.4-rc300.4 aldeed:simple-schema@2.0.0-beta300.0 aldeed:collection2@4.0.2-beta.2 -meteortesting:mocha@3.1.0-beta300.0 -meteortesting:mocha-core@8.3.0-beta300.0 +meteortesting:mocha@3.2.0 diff --git a/tests/.meteor/release b/tests/.meteor/release index b1e86a3..db05bb3 100644 --- a/tests/.meteor/release +++ b/tests/.meteor/release @@ -1 +1 @@ -METEOR@3.0.4 +METEOR@3.2 diff --git a/tests/.meteor/versions b/tests/.meteor/versions index 30e1062..5534bfa 100644 --- a/tests/.meteor/versions +++ b/tests/.meteor/versions @@ -1,9 +1,9 @@ -aldeed:collection2@4.0.4 +aldeed:collection2@4.2.0-beta.1 aldeed:simple-schema@2.0.0 -allow-deny@2.0.0 +allow-deny@2.1.0 autopublish@1.0.8 autoupdate@2.0.0 -babel-compiler@7.11.1 +babel-compiler@7.11.3 babel-runtime@1.5.2 base64@1.0.13 binary-heap@1.0.12 @@ -12,19 +12,19 @@ callback-hook@1.6.0 check@1.4.4 core-runtime@1.0.0 ddp@1.4.2 -ddp-client@3.0.2 +ddp-client@3.1.0 ddp-common@1.4.4 -ddp-server@3.0.2 +ddp-server@3.1.0 diff-sequence@1.1.3 dynamic-import@0.7.4 -ecmascript@0.16.9 +ecmascript@0.16.10 ecmascript-runtime@0.8.3 -ecmascript-runtime-client@0.12.2 +ecmascript-runtime-client@0.12.3 ecmascript-runtime-server@0.11.1 ejson@1.1.4 es5-shim@4.8.1 facts-base@1.0.2 -fetch@0.1.5 +fetch@0.1.6 geojson-utils@1.0.12 hot-code-push@1.0.5 http@1.0.1 @@ -32,23 +32,23 @@ id-map@1.2.0 insecure@1.0.8 inter-process-messaging@0.1.2 jquery@3.0.0 -logging@1.3.5 -meteor@2.0.1 +logging@1.3.6 +meteor@2.1.0 meteor-base@1.5.2 meteortesting:browser-tests@1.6.0-beta300.0 meteortesting:mocha@3.1.0-beta300.0 meteortesting:mocha-core@8.3.1-beta300.0 -minifier-css@2.0.0 -minifier-js@3.0.0 -minimongo@2.0.1 -modern-browsers@0.1.11 -modules@0.20.2 +minifier-css@2.0.1 +minifier-js@3.0.1 +minimongo@2.0.2 +modern-browsers@0.2.1 +modules@0.20.3 modules-runtime@0.13.2 -mongo@2.0.2 -mongo-decimal@0.1.4-beta300.7 +mongo@2.1.1 +mongo-decimal@0.2.0 mongo-dev-server@1.1.1 mongo-id@1.0.9 -npm-mongo@4.17.4 +npm-mongo@6.10.2 ordered-dict@1.2.0 promise@1.0.0 raix:eventemitter@1.0.0 @@ -58,13 +58,13 @@ reactive-var@1.0.13 reload@1.3.2 retry@1.1.1 routepolicy@1.1.2 -shell-server@0.6.0 -socket-stream-client@0.5.3 +shell-server@0.6.1 +socket-stream-client@0.6.0 standard-minifier-css@1.9.3 standard-minifier-js@3.0.0 tracker@1.3.4 -typescript@5.4.3 +typescript@5.6.3 underscore@1.6.4 -webapp@2.0.3 +webapp@2.0.5 webapp-hashing@1.1.2 zodern:types@1.0.13 diff --git a/tests/ajv.tests.js b/tests/ajv.tests.js new file mode 100644 index 0000000..1dfd33e --- /dev/null +++ b/tests/ajv.tests.js @@ -0,0 +1,43 @@ +/* eslint-env mocha */ +import Ajv from 'ajv' +import expect from 'expect' +import { callMongoMethod } from './helper' + +describe('using ajv', () => { + it('attach and get ajv for normal collection', function () { + ['ajvMc1', null].forEach(name => { + const mc = new Mongo.Collection(name, Meteor.isClient ? { connection: null } : undefined); + + mc.attachSchema({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + additionalProperties: false, + }); + + // Check if the schema was correctly detected as an AJV schema + expect(mc.c2Schema()).toBeDefined(); + expect(mc.c2Schema().type).toBe("object"); + expect(mc.c2Schema().properties.foo.type).toBe("string"); + }); + }); + + it('handles prototype-less objects', async function () { + const prototypelessTest = new Mongo.Collection( + 'prototypelessTestAjv', + Meteor.isClient ? { connection: null } : undefined + ); + + prototypelessTest.attachSchema({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + additionalProperties: false, + }); + + const prototypelessObject = Object.create(null); + prototypelessObject.foo = 'bar'; + + await callMongoMethod(prototypelessTest, 'insert', [prototypelessObject]); + }); +}) \ No newline at end of file diff --git a/tests/autoValue.tests.js b/tests/autoValue.tests.js index 8a10950..b7aa52d 100644 --- a/tests/autoValue.tests.js +++ b/tests/autoValue.tests.js @@ -1,41 +1,48 @@ -/* eslint-env mocha */ import 'meteor/aldeed:collection2/static'; import { Meteor } from 'meteor/meteor'; -import expect from 'expect'; import { Mongo } from 'meteor/mongo'; +import expect from 'expect'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { callMongoMethod } from './helper'; +import { Collection2 } from 'meteor/aldeed:collection2' const collection = new Mongo.Collection('autoValueTestCollection'); const localCollection = new Mongo.Collection('autoValueTestLocalCollection', { connection: null }); -[collection, localCollection].forEach((c) => { - c.attachSchema( - new SimpleSchema({ - clientAV: { - type: SimpleSchema.Integer, - optional: true, - autoValue() { - if (Meteor.isServer) return; - return (this.value || 0) + 1; - } - }, - serverAV: { - type: SimpleSchema.Integer, - optional: true, - autoValue() { - if (Meteor.isClient) return; - return (this.value || 0) + 1; +const attach = () => { + [collection, localCollection].forEach((c) => { + c.attachSchema( + new SimpleSchema({ + clientAV: { + type: SimpleSchema.Integer, + optional: true, + autoValue() { + if (Meteor.isServer) return; + return (this.value || 0) + 1; + } + }, + serverAV: { + type: SimpleSchema.Integer, + optional: true, + autoValue() { + console.debug('get autovalues', Meteor.isClient) + if (Meteor.isClient) return; + return (this.value || 0) + 1; + } } - } - }) - ); -}); + }) + ); + }); +} if (Meteor.isClient) { describe('autoValue on client', function () { + before(() => { + attach() + }) + it('for client insert, autoValues should be added on the server only (added to only a validated clone of the doc on client)', function (done) { collection.insert({}, (error, id) => { if (error) { @@ -79,6 +86,9 @@ if (Meteor.isClient) { if (Meteor.isServer) { describe('autoValue on server', function () { + before(() => { + attach() + }) it('runs function once', async function () { const id = await callMongoMethod(collection, 'insert', [{}]); const doc = await callMongoMethod(collection, 'findOne', [id]); diff --git a/tests/books.tests.js b/tests/books.tests.js index 370cb08..45e32f7 100644 --- a/tests/books.tests.js +++ b/tests/books.tests.js @@ -4,6 +4,7 @@ import SimpleSchema from "meteor/aldeed:simple-schema"; import { Meteor } from 'meteor/meteor'; import { _ } from 'meteor/underscore'; import { callMeteorFetch, callMongoMethod } from './helper'; +import { Collection2 } from 'meteor/aldeed:collection2' /* global describe, it, beforeEach */ @@ -57,17 +58,19 @@ const booksSchema = new SimpleSchema({ }); const books = new Mongo.Collection('books'); -books.attachSchema(booksSchema); - const upsertTest = new Mongo.Collection('upsertTest'); -upsertTest.attachSchema( - new SimpleSchema({ - _id: { type: String }, - foo: { type: Number } + +describe('SimpleSchema books tests', () => { + before(() => { + books.attachSchema(booksSchema); + upsertTest.attachSchema( + new SimpleSchema({ + _id: { type: String }, + foo: { type: Number } + }) + ); }) -); -export default function addBooksTests() { describe('insert', function () { beforeEach(async function () { for (const book of await callMeteorFetch(books, {})) { @@ -81,6 +84,20 @@ export default function addBooksTests() { { title: 'Ulysses', author: 'James Joyce' + }, + (error, result) => { + // The insert will fail, error will be set, + expect(!!error).toBe(true); + // and the result will be false because "copies" is required. + expect(result).toBe(false); + // The list of errors is available by calling books.c2Schema().namedContext().validationErrors() + const validationErrors = books.c2Schema().namedContext().validationErrors(); + expect(validationErrors.length).toBe(1); + + const key = validationErrors[0] || {}; + expect(key.name).toBe('copies'); + expect(key.type).toBe('required'); + maybeNext(); } ]) .then((result) => { @@ -90,7 +107,7 @@ export default function addBooksTests() { // The insert will fail, error will be set, expect(!!error).toBe(true); // The list of errors is available by calling books.simpleSchema().namedContext().validationErrors() - const validationErrors = books.simpleSchema().namedContext().validationErrors(); + const validationErrors = books.c2Schema().namedContext().validationErrors(); expect(validationErrors.length).toBe(1); const key = validationErrors[0] || {}; @@ -118,7 +135,7 @@ export default function addBooksTests() { }) .catch(async (error) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse') .validationErrors(); @@ -146,7 +163,7 @@ export default function addBooksTests() { } ]).then(async (newId) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse2') .validationErrors(); @@ -177,7 +194,7 @@ export default function addBooksTests() { }) .catch(async (error) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse3') .validationErrors(); @@ -210,7 +227,7 @@ export default function addBooksTests() { } ]).then(async (result) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse4') .validationErrors(); expect(result).toBe(1); @@ -245,8 +262,8 @@ export default function addBooksTests() { // and result will be false because "copies" is required. // TODO expect(result).toBe(false); // The list of errors is available - // by calling books.simpleSchema().namedContext().validationErrors() - const validationErrors = books.simpleSchema().namedContext().validationErrors(); + // by calling books.c2Schema().namedContext().validationErrors() + const validationErrors = books.c2Schema().namedContext().validationErrors(); expect(validationErrors.length).toBe(1); const key = validationErrors[0] || {}; @@ -280,7 +297,7 @@ export default function addBooksTests() { } let validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse2') .validationErrors(); @@ -312,7 +329,7 @@ export default function addBooksTests() { } let updatedBook; - validationErrors = books.simpleSchema().namedContext('validateFalse3').validationErrors(); + validationErrors = books.c2Schema().namedContext('validateFalse3').validationErrors(); // When validated: false on the server, validation should be skipped expect(!!error).toBe(false); @@ -346,7 +363,7 @@ export default function addBooksTests() { error = e; } - validationErrors = books.simpleSchema().namedContext('validateFalse4').validationErrors(); + validationErrors = books.c2Schema().namedContext('validateFalse4').validationErrors(); expect(!!error).toBe(false); expect(result).toBe(1); expect(validationErrors.length).toBe(0); @@ -376,34 +393,34 @@ export default function addBooksTests() { if (Meteor.isServer) { describe('upsert', function () { - function getCallback(done) { + function getCallback (done) { return (result) => { expect(result.numberAffected).toBe(1); - const validationErrors = books.simpleSchema().namedContext().validationErrors(); + const validationErrors = books.c2Schema().namedContext().validationErrors(); expect(validationErrors.length).toBe(0); done(); }; } - function getUpdateCallback(done) { + function getUpdateCallback (done) { return (result) => { expect(result).toBe(1); - const validationErrors = books.simpleSchema().namedContext().validationErrors(); + const validationErrors = books.c2Schema().namedContext().validationErrors(); expect(validationErrors.length).toBe(0); done(); }; } - function getErrorCallback(done) { + function getErrorCallback (done) { return (error) => { expect(!!error).toBe(true); // expect(!!result).toBe(false) - const validationErrors = books.simpleSchema().namedContext().validationErrors(); + const validationErrors = books.c2Schema().namedContext().validationErrors(); expect(validationErrors.length).toBe(1); done(); @@ -578,7 +595,7 @@ export default function addBooksTests() { ]) .then(async (result) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse') .validationErrors(); @@ -606,7 +623,7 @@ export default function addBooksTests() { newId = _newId; const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse2') .validationErrors(); @@ -635,7 +652,7 @@ export default function addBooksTests() { }) .then((result) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse3') .validationErrors(); @@ -666,7 +683,7 @@ export default function addBooksTests() { }) .then((result) => { const validationErrors = books - .simpleSchema() + .c2Schema() .namedContext('validateFalse4') .validationErrors(); expect(result).toBe(1); @@ -733,4 +750,4 @@ export default function addBooksTests() { expect(doc.foo).toBe(2); }); } -} +}); diff --git a/tests/clean.tests.js b/tests/clean.tests.js index c2d8f2c..036d261 100644 --- a/tests/clean.tests.js +++ b/tests/clean.tests.js @@ -1,11 +1,11 @@ +/* eslint-env mocha */ import expect from 'expect'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { Meteor } from 'meteor/meteor'; +import { Collection2 } from 'meteor/aldeed:collection2' import { callMongoMethod } from './helper'; -/* global describe it */ - let collection; if (Meteor.isClient) { @@ -14,7 +14,7 @@ if (Meteor.isClient) { collection = new Mongo.Collection('cleanTests'); } -describe('clean options', function () { +describe('SimpleSchema clean options', function () { describe('filter', function () { it('keeps default schema clean options', function (done) { const schema = new SimpleSchema( diff --git a/tests/collection2.tests.js b/tests/collection2.tests.js index 7df7f69..a85c364 100644 --- a/tests/collection2.tests.js +++ b/tests/collection2.tests.js @@ -1,12 +1,9 @@ import expect from 'expect'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from 'meteor/aldeed:simple-schema'; -import addMultiTests from './multi.tests.js'; -import addBooksTests from './books.tests.js'; -import addContextTests from './context.tests.js'; -import addDefaultValuesTests from './default.tests.js'; import { Meteor } from 'meteor/meteor'; import { callMongoMethod } from './helper'; +import { Collection2 } from 'meteor/aldeed:collection2' /* global describe, it */ @@ -20,7 +17,7 @@ describe('collection2', function () { }) ); - expect(mc.simpleSchema() instanceof SimpleSchema).toBe(true); + expect(mc.c2Schema() instanceof SimpleSchema).toBe(true); }); it('attach and get simpleSchema for local collection', function () { @@ -32,7 +29,7 @@ describe('collection2', function () { }) ); - expect(mc.simpleSchema() instanceof SimpleSchema).toBe(true); + expect(mc.c2Schema() instanceof SimpleSchema).toBe(true); }); it('handles prototype-less objects', async function () { @@ -55,6 +52,23 @@ describe('collection2', function () { await callMongoMethod(prototypelessTest, 'insert', [prototypelessObject]); }); + it('SimpleSchema property-based detection', function() { + const mc = new Mongo.Collection('simpleSchemaDetection', Meteor.isClient ? { connection: null } : undefined); + + const schema = new SimpleSchema({ + name: { type: String }, + age: { type: Number, optional: true } + }); + + mc.attachSchema(schema); + + expect(mc.c2Schema()).toBeDefined(); + expect(mc.c2Schema() instanceof SimpleSchema).toBe(true); + + expect(mc.c2Schema()._schema).toBeDefined(); + expect(mc.c2Schema()._schema.name).toBeDefined(); + }); + if (Meteor.isServer) { // https://github.com/aldeed/meteor-collection2/issues/243 it('upsert runs autoValue only once', async function () { @@ -731,8 +745,4 @@ describe('collection2', function () { } }); - addBooksTests(); - addContextTests(); - addDefaultValuesTests(); - addMultiTests(); }); diff --git a/tests/context.tests.js b/tests/context.tests.js index 6576feb..392be6c 100644 --- a/tests/context.tests.js +++ b/tests/context.tests.js @@ -1,10 +1,11 @@ +/* eslint-env mocha */ import expect from 'expect'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { Meteor } from 'meteor/meteor'; import { callMongoMethod } from './helper'; +import { Collection2 } from 'meteor/aldeed:collection2' -/* global it */ const contextCheckSchema = new SimpleSchema({ foo: { @@ -54,9 +55,13 @@ const contextCheckSchema = new SimpleSchema({ }); const contextCheck = new Mongo.Collection('contextCheck'); -contextCheck.attachSchema(contextCheckSchema); -export default function addContextTests() { + +describe('context tests', () => { + before(() => { + contextCheck.attachSchema(contextCheckSchema); + }) + it('AutoValue Context', async function () { const testId = await callMongoMethod(contextCheck, 'insert', [{}]); @@ -101,4 +106,4 @@ export default function addContextTests() { ctx = await callMongoMethod(contextCheck, 'findOne', [testId]); expect(ctx.context.docId).toBe(testId); }); -} +}); diff --git a/tests/default.tests.js b/tests/default.tests.js index 412b3e7..8705f65 100644 --- a/tests/default.tests.js +++ b/tests/default.tests.js @@ -1,10 +1,10 @@ +/* eslint-env mocha */ import expect from 'expect'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { Meteor } from 'meteor/meteor'; import { callMongoMethod } from './helper'; - -/* global it */ +import { Collection2 } from 'meteor/aldeed:collection2' const defaultValuesSchema = new SimpleSchema({ bool1: { @@ -14,10 +14,13 @@ const defaultValuesSchema = new SimpleSchema({ }); const defaultValues = new Mongo.Collection('dv'); -defaultValues.attachSchema(defaultValuesSchema); global.defaultValues = defaultValues; -export default function addDefaultValuesTests() { +describe('defaults tests', () => { + before(() => { + defaultValues.attachSchema(defaultValuesSchema); + }) + if (Meteor.isServer) { it('defaultValues', function (done) { let p; @@ -81,4 +84,4 @@ export default function addDefaultValuesTests() { expect(p.bool1).toBe(true); }); } -} +}); diff --git a/tests/main.tests.js b/tests/main.tests.js new file mode 100644 index 0000000..c9b1888 --- /dev/null +++ b/tests/main.tests.js @@ -0,0 +1,10 @@ +import 'meteor/aldeed:collection2/static'; +import './autoValue.tests' +import './clean.tests' +import './collection2.tests' +import './multi.tests.js'; +import './books.tests.js'; +import './context.tests.js'; +import './default.tests.js'; +import './ajv.tests' +import './zod.tests.js' \ No newline at end of file diff --git a/tests/multi.tests.js b/tests/multi.tests.js index 5ffd5a1..ecb8cdd 100644 --- a/tests/multi.tests.js +++ b/tests/multi.tests.js @@ -1,11 +1,10 @@ +/* eslint-env mocha */ import expect from 'expect'; import { Mongo } from 'meteor/mongo'; import SimpleSchema from "meteor/aldeed:simple-schema"; import { Meteor } from 'meteor/meteor'; import { callMeteorFetch, callMongoMethod } from './helper'; -/* global describe, it, beforeEach */ - const productSchema = new SimpleSchema({ _id: { type: String, @@ -69,495 +68,496 @@ extendedProductSchema.extend({ // Need to define the client one on both client and server let products = new Mongo.Collection('TestProductsClient'); -products.attachSchema(productSchema, { selector: { type: 'simple' } }); -products.attachSchema(productVariantSchema, { selector: { type: 'variant' } }); -if (Meteor.isServer) { - products = new Mongo.Collection('TestProductsServer'); - products.attachSchema(productSchema, { selector: { type: 'simple' } }); - products.attachSchema(productVariantSchema, { - selector: { type: 'variant' } - }); -} +let extendedProducts = new Mongo.Collection('ExtendedProductsClient'); + +describe('multiple top-level schemas', function () { + before(() => { + products.attachSchema(productSchema, { selector: { type: 'simple' } }); + products.attachSchema(productVariantSchema, { selector: { type: 'variant' } }); + if (Meteor.isServer) { + products = new Mongo.Collection('TestProductsServer'); + products.attachSchema(productSchema, { selector: { type: 'simple' } }); + products.attachSchema(productVariantSchema, { + selector: { type: 'variant' } + }); + } -/* Extended Products */ + /* Extended Products */ // Need to define the client one on both client and server -let extendedProducts = new Mongo.Collection('ExtendedProductsClient'); -extendedProducts.attachSchema(productSchema, { selector: { type: 'simple' } }); -extendedProducts.attachSchema(productVariantSchema, { - selector: { type: 'variant' } -}); -extendedProducts.attachSchema(extendedProductSchema, { - selector: { type: 'simple' } -}); -if (Meteor.isServer) { - extendedProducts = new Mongo.Collection('ExtendedProductsServer'); - extendedProducts.attachSchema(productSchema, { - selector: { type: 'simple' } - }); - extendedProducts.attachSchema(productVariantSchema, { - selector: { type: 'variant' } - }); - extendedProducts.attachSchema(extendedProductSchema, { - selector: { type: 'simple' } - }); -} - -export default function addMultiTests() { - describe('multiple top-level schemas', function () { - beforeEach(async function () { - for (const doc of await callMeteorFetch(products, {})) { - await callMongoMethod(products, 'remove', [doc._id]); - } - - for (const doc of await callMeteorFetch(extendedProducts, {})) { - await callMongoMethod(products, 'remove', [doc._id]); - } - - /* - for await (const doc of products.find({})) { - await products.removeAsync(doc._id); - } - - for await (const doc of extendedProducts.find({})) { - await products.removeAsync(doc._id); - } - */ + extendedProducts.attachSchema(productSchema, { selector: { type: 'simple' } }); + extendedProducts.attachSchema(productVariantSchema, { + selector: { type: 'variant' } }); + extendedProducts.attachSchema(extendedProductSchema, { + selector: { type: 'simple' } + }); + if (Meteor.isServer) { + extendedProducts = new Mongo.Collection('ExtendedProductsServer'); + extendedProducts.attachSchema(productSchema, { + selector: { type: 'simple' } + }); + extendedProducts.attachSchema(productVariantSchema, { + selector: { type: 'variant' } + }); + extendedProducts.attachSchema(extendedProductSchema, { + selector: { type: 'simple' } + }); + } + }) + beforeEach(async function () { + for (const doc of await callMeteorFetch(products, {})) { + await callMongoMethod(products, 'remove', [doc._id]); + } - it('works', function () { - const c = new Mongo.Collection('multiSchema'); + for (const doc of await callMeteorFetch(extendedProducts, {})) { + await callMongoMethod(products, 'remove', [doc._id]); + } - // Attach two different schemas - c.attachSchema( - new SimpleSchema({ - one: { type: String } - }) - ); - c.attachSchema( - new SimpleSchema({ - two: { type: String } - }) - ); + /* + for await (const doc of products.find({})) { + await products.removeAsync(doc._id); + } - // Check the combined schema - let combinedSchema = c.simpleSchema(); - expect(combinedSchema._schemaKeys.includes('one')).toBe(true); - expect(combinedSchema._schemaKeys.includes('two')).toBe(true); - expect(combinedSchema.schema('two').type).toEqual(SimpleSchema.oneOf(String)); - - // Attach a third schema and make sure that it extends/overwrites the others - c.attachSchema( - new SimpleSchema({ - two: { type: SimpleSchema.Integer } - }) - ); - combinedSchema = c.simpleSchema(); - expect(combinedSchema._schemaKeys.includes('one')).toBe(true); - expect(combinedSchema._schemaKeys.includes('two')).toBe(true); - expect(combinedSchema.schema('two').type).toEqual(SimpleSchema.oneOf(SimpleSchema.Integer)); - - // Ensure that we've only attached two deny functions - expect(c._validators.insert.deny.length).toBe(2); - expect(c._validators.update.deny.length).toBe(2); - }); + for await (const doc of extendedProducts.find({})) { + await products.removeAsync(doc._id); + } + */ + }); - if (Meteor.isServer) { - it('inserts doc correctly with selector passed via doc', async function () { - const productId = await callMongoMethod(products, 'insert', [ - { - title: 'Product one', - type: 'simple' // selector in doc - } - ]); + it('works', function () { + const c = new Mongo.Collection('multiSchema'); + + // Attach two different schemas + c.attachSchema( + new SimpleSchema({ + one: { type: String } + }) + ); + c.attachSchema( + new SimpleSchema({ + two: { type: String } + }) + ); + + // Check the combined schema + let combinedSchema = c.c2Schema(); + expect(combinedSchema._schemaKeys.includes('one')).toBe(true); + expect(combinedSchema._schemaKeys.includes('two')).toBe(true); + expect(combinedSchema.schema('two').type).toEqual(SimpleSchema.oneOf(String)); + + // Attach a third schema and make sure that it extends/overwrites the others + c.attachSchema( + new SimpleSchema({ + two: { type: SimpleSchema.Integer } + }) + ); + combinedSchema = c.c2Schema(); + expect(combinedSchema._schemaKeys.includes('one')).toBe(true); + expect(combinedSchema._schemaKeys.includes('two')).toBe(true); + expect(combinedSchema.schema('two').type).toEqual(SimpleSchema.oneOf(SimpleSchema.Integer)); + + // Ensure that we've only attached two deny functions + expect(c._validators.insert.deny.length).toBe(2); + expect(c._validators.update.deny.length).toBe(2); + }); - const product = await callMongoMethod(products, 'findOne', [productId]); - expect(product.description).toBe('This is a simple product.'); - expect(product.price).toBe(undefined); + if (Meteor.isServer) { + it('inserts doc correctly with selector passed via doc', async function () { + const productId = await callMongoMethod(products, 'insert', [ + { + title: 'Product one', + type: 'simple' // selector in doc + } + ]); + + const product = await callMongoMethod(products, 'findOne', [productId]); + expect(product.description).toBe('This is a simple product.'); + expect(product.price).toBe(undefined); + + const productId3 = await callMongoMethod(products, 'insert', [ + { + title: 'Product three', + createdAt: new Date(), + type: 'variant' // other selector in doc + } + ]); + const product3 = await callMongoMethod(products, 'findOne', [productId3]); + expect(product3.description).toBe(undefined); + expect(product3.price).toBe(5); + }); - const productId3 = await callMongoMethod(products, 'insert', [ - { - title: 'Product three', - createdAt: new Date(), - type: 'variant' // other selector in doc - } - ]); - const product3 = await callMongoMethod(products, 'findOne', [productId3]); - expect(product3.description).toBe(undefined); - expect(product3.price).toBe(5); - }); + // Passing selector in options works only on the server because + // client options are not sent to the server and made availabe in + // the deny functions, where we call .c2Schema() + // + // Also synchronous only works on server + it('insert selects the correct schema', async function () { + const productId = await callMongoMethod(products, 'insert', [ + { + title: 'Product one' + }, + { selector: { type: 'simple' } } + ]); + + const productVariantId = await callMongoMethod(products, 'insert', [ + { + title: 'Product variant one', + createdAt: new Date() + }, + { selector: { type: 'variant' } } + ]); + + const product = await callMongoMethod(products, 'findOne', [productId]); + const productVariant = await callMongoMethod(products, 'findOne', [productVariantId]); + + // we should receive new docs with correct property set for each type of doc + expect(product.description).toBe('This is a simple product.'); + expect(product.price).toBe(undefined); + expect(productVariant.description).toBe(undefined); + expect(productVariant.price).toBe(5); + }); - // Passing selector in options works only on the server because - // client options are not sent to the server and made availabe in - // the deny functions, where we call .simpleSchema() - // - // Also synchronous only works on server - it('insert selects the correct schema', async function () { - const productId = await callMongoMethod(products, 'insert', [ - { - title: 'Product one' - }, - { selector: { type: 'simple' } } - ]); - - const productVariantId = await callMongoMethod(products, 'insert', [ - { - title: 'Product variant one', - createdAt: new Date() - }, - { selector: { type: 'variant' } } - ]); - - const product = await callMongoMethod(products, 'findOne', [productId]); - const productVariant = await callMongoMethod(products, 'findOne', [productVariantId]); - - // we should receive new docs with correct property set for each type of doc - expect(product.description).toBe('This is a simple product.'); - expect(product.price).toBe(undefined); - expect(productVariant.description).toBe(undefined); - expect(productVariant.price).toBe(5); - }); + it('inserts doc correctly with selector passed via doc and via