├── .gitignore ├── .versions ├── CHANGELOG.md ├── CustomType.js ├── CustomTypeCollection.js ├── EJSONType.js ├── README.md ├── collections.js └── package.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /.versions: -------------------------------------------------------------------------------- 1 | allow-deny@1.1.1 2 | babel-compiler@7.10.3 3 | babel-runtime@1.5.1 4 | base64@1.0.12 5 | binary-heap@1.0.11 6 | boilerplate-generator@1.7.1 7 | callback-hook@1.5.0 8 | check@1.3.2 9 | ddp@1.4.1 10 | ddp-client@2.6.1 11 | ddp-common@1.4.0 12 | ddp-server@2.6.0 13 | diff-sequence@1.1.2 14 | dynamic-import@0.7.2 15 | ecmascript@0.16.6 16 | ecmascript-runtime@0.8.0 17 | ecmascript-runtime-client@0.12.1 18 | ecmascript-runtime-server@0.11.0 19 | ejson@1.1.3 20 | fetch@0.1.3 21 | geojson-utils@1.0.11 22 | id-map@1.1.1 23 | inter-process-messaging@0.1.1 24 | logging@1.3.2 25 | meteor@1.11.1 26 | minimongo@1.9.2 27 | modern-browsers@0.1.9 28 | modules@0.19.0 29 | modules-runtime@0.13.1 30 | mongo@1.16.5 31 | mongo-decimal@0.1.3 32 | mongo-dev-server@1.1.0 33 | mongo-id@1.0.8 34 | npm-mongo@4.14.0 35 | ordered-dict@1.1.0 36 | promise@0.12.2 37 | quave:collections@1.1.0 38 | quave:settings@1.0.0 39 | random@1.2.1 40 | react-fast-refresh@0.2.6 41 | reload@1.3.1 42 | retry@1.1.0 43 | routepolicy@1.1.1 44 | socket-stream-client@0.5.0 45 | tracker@1.3.1 46 | underscore@1.0.12 47 | webapp@1.13.4 48 | webapp-hashing@1.1.1 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## CHANGELOG 2 | 3 | ### 1.1.0 (2023-03-13) 4 | 5 | - Composer now receives the collection object from Meteor already assigned to the `collection` property. Before it was getting only the collection property. 6 | -------------------------------------------------------------------------------- /CustomType.js: -------------------------------------------------------------------------------- 1 | import { EJSON } from 'meteor/ejson'; 2 | 3 | import { getSettings } from 'meteor/quave:settings'; 4 | 5 | const PACKAGE_NAME = 'quave:collections'; 6 | const settings = getSettings({ packageName: PACKAGE_NAME }); 7 | 8 | const { isVerbose } = settings; 9 | 10 | export const CustomTypes = { 11 | scalarAndEjson(type) { 12 | return { 13 | name: type.name(), 14 | description: type.description(), 15 | serialize: obj => obj, 16 | parseValue: obj => obj, 17 | }; 18 | }, 19 | }; 20 | 21 | /* eslint-disable class-methods-use-this */ 22 | export class CustomType { 23 | constructor() { 24 | this.register(); 25 | } 26 | 27 | register() { 28 | // Type is already present 29 | if (!EJSON._getTypes()[this.name()]) { 30 | if (isVerbose) { 31 | console.log( 32 | `${PACKAGE_NAME} EJSON.addType ${this.name()} from TypeDef class` 33 | ); 34 | } 35 | EJSON.addType(this.name(), json => this.fromJSONValue(json)); 36 | } 37 | } 38 | 39 | name() { 40 | throw new Error( 41 | `name() needs to be implemented in ${this.constructor.name}` 42 | ); 43 | } 44 | 45 | toSimpleSchema() { 46 | throw new Error( 47 | `toSimpleSchema() needs to be implemented in ${this.constructor.name}` 48 | ); 49 | } 50 | 51 | description() { 52 | return ''; 53 | } 54 | 55 | fromJSONValue(json) { 56 | return json; 57 | } 58 | 59 | toPersist(obj) { 60 | if (obj !== undefined && obj !== null) { 61 | return this.doToPersist(obj); 62 | } 63 | return obj; 64 | } 65 | 66 | fromPersisted(obj) { 67 | if (obj !== undefined && obj !== null) { 68 | return this.doFromPersisted(obj); 69 | } 70 | return obj; 71 | } 72 | 73 | doToPersist(obj) { 74 | throw new Error(obj); 75 | } 76 | 77 | doFromPersisted(obj) { 78 | return obj; 79 | } 80 | 81 | doParseLiteral(ast) { 82 | if (ast.kind === 'IntValue') { 83 | const result = parseInt(ast.value, 10); 84 | return this.doFromPersisted(result); // ast value is always in string format 85 | } 86 | return null; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /CustomTypeCollection.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo'; 2 | import { EJSON } from 'meteor/ejson'; 3 | 4 | import { getSettings } from 'meteor/quave:settings'; 5 | 6 | const PACKAGE_NAME = 'quave:collections'; 7 | const settings = getSettings({ packageName: PACKAGE_NAME }); 8 | 9 | const { isVerbose } = settings; 10 | 11 | const lookForTypesAndApply = (definition, obj, consumeType) => { 12 | if (obj) { 13 | Object.entries(definition.fields).forEach(([key, value]) => { 14 | if (!(key in obj)) { 15 | return; 16 | } 17 | if (!EJSON._getTypes()[value.typeName]) { 18 | return; 19 | } 20 | 21 | // is not a subtype 22 | if (!value.fields) { 23 | if (Array.isArray(value)) { 24 | // eslint-disable-next-line no-param-reassign 25 | obj[key] = obj[key].map(v => consumeType(value[0].customType, v)); 26 | } else { 27 | // eslint-disable-next-line no-param-reassign 28 | obj[key] = consumeType(value.customType, obj[key]); 29 | } 30 | } else { 31 | const subtype = value; 32 | const arr = Array.isArray(obj[key]) ? obj[key] : [obj[key]]; 33 | const newArr = []; 34 | for (let i = 0; i < arr.length; i++) { 35 | const v = { ...arr[i] }; 36 | newArr.push(v); 37 | lookForTypesAndApply(subtype, v, consumeType); 38 | } 39 | // eslint-disable-next-line no-param-reassign 40 | obj[key] = Array.isArray(obj[key]) ? newArr : newArr[0]; 41 | } 42 | }); 43 | } 44 | return obj; 45 | }; 46 | 47 | const onPersistCollection = (definition, obj) => { 48 | return lookForTypesAndApply(definition, obj, (parser, value) => { 49 | return parser.toPersist(value); 50 | }); 51 | }; 52 | 53 | const onLoadFromCollection = (definition, obj) => 54 | lookForTypesAndApply(definition, obj, (parser, value) => 55 | parser.fromPersisted(value) 56 | ); 57 | 58 | export const CustomTypeCollection = { 59 | createTypedCollection: (name, definition, opts) => { 60 | if (!definition) { 61 | throw new Error(`"definition" option was not found for "${name}"`); 62 | } 63 | const collection = new Mongo.Collection(name, { 64 | transform: obj => onLoadFromCollection(definition, obj), 65 | }); 66 | if (!collection.before) { 67 | console.warn( 68 | 'If you want to automatically convert your types before persisting on MongoDB please add this package https://github.com/Meteor-Community-Packages/meteor-collection-hooks' 69 | ); 70 | } else { 71 | // registerHooks 72 | collection.before.insert((_, obj) => { 73 | onPersistCollection(definition, obj); 74 | }); 75 | collection.before.update((userId, doc, fields, set) => { 76 | onPersistCollection(definition, set.$set); 77 | }); 78 | } 79 | 80 | if (opts && opts.helpers && Object.keys(opts.helpers).length) { 81 | // :-( private access 82 | const transformType = collection._transform; 83 | collection._transform = null; 84 | if (!collection.helpers) { 85 | throw new Error( 86 | "You need to add this package https://github.com/dburles/meteor-collection-helpers to use 'helpers'" 87 | ); 88 | } 89 | collection.helpers(opts.helpers); 90 | const transformHelpers = collection._transform; 91 | 92 | collection._transform = doc => transformType(transformHelpers(doc)); 93 | } 94 | return collection; 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /EJSONType.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | export class EJSONType { 3 | typeName() { 4 | return this.constructor.name; 5 | } 6 | 7 | toJSONValue() { 8 | throw new Error( 9 | `toJSONValue() needs to be implemented in ${this.typeName()}` 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quave:collections 2 | 3 | ### MIGRATED TO https://github.com/quavedev/meteor-packages/tree/main/collections 4 | 5 | `quave:collections` is a Meteor package that allows you to create your collections in a standard way. 6 | 7 | Features 8 | 9 | - Schemas 10 | - Types 11 | - Helpers 12 | - Hooks 13 | - Composers 14 | 15 | ## Why 16 | 17 | Every application that connects to databases usually need the following features: 18 | 19 | - A way to access object instances when they come from the database: helpers 20 | - Provide new methods to collections: collection 21 | - Add a few hooks to react to changes in different collections: hooks 22 | - Map some types to avoid manual conversion all the time: types 23 | - Valid the data before persisting: schemas 24 | - Centralize behaviors: composers 25 | 26 | Meteor has packages for almost all these use cases but it's not easy to find the best in each case and also how to use them together, that is why we have created this package. 27 | 28 | We offer here a standard way for you to create your collections by configuring all these features in a function call `createCollection` using a bunch of options in a declarative way and without using Javascript classes. 29 | 30 | We also allow you to extend your `Meteor.users` collection in the same way as any other collection. 31 | 32 | We believe we are not reinventing the wheel in this package but what we are doing is like putting together the wheels in the vehicle :). 33 | 34 | ## Installation 35 | 36 | ```sh 37 | meteor add quave:collections 38 | ``` 39 | 40 | ### Optional installations 41 | 42 | To use Type or Hooks options you need to install [meteor-collection-hooks](https://github.com/Meteor-Community-Packages/meteor-collection-hooks) 43 | 44 | ```sh 45 | meteor add matb33:collection-hooks 46 | ``` 47 | 48 | To use Schema options you need to install [meteor-collection2](https://github.com/Meteor-Community-Packages/meteor-collection2) 49 | 50 | ```sh 51 | meteor add aldeed:collection2 52 | meteor npm install simpl-schema 53 | ``` 54 | 55 | To use Helpers options you need to install [meteor-collection-helpers](https://github.com/dburles/meteor-collection-helpers) 56 | 57 | ```sh 58 | meteor add dburles:collection-helpers 59 | ``` 60 | 61 | Check the documentation of each package to learn how to use them. 62 | 63 | ## Usage 64 | 65 | ### Methods 66 | 67 | Example applying `collection` property: 68 | 69 | ```javascript 70 | import { createCollection } from 'meteor/quave:collections'; 71 | 72 | export const AddressesCollection = createCollection({ 73 | name: 'addresses', 74 | collection: { 75 | save(addressParam) { 76 | const address = { ...addressParam }; 77 | 78 | if (address._id) { 79 | this.update(address._id, { $set: { ...address } }); 80 | return address._id; 81 | } 82 | delete address._id; 83 | return this.insert({ ...address }); 84 | }, 85 | }, 86 | }); 87 | ``` 88 | 89 | ### Schema 90 | 91 | Example applying `SimpleSchema`: 92 | 93 | ```javascript 94 | import { createCollection } from 'meteor/quave:collections'; 95 | 96 | import SimpleSchema from 'simpl-schema'; 97 | 98 | const PlayerSchema = new SimpleSchema({ 99 | name: { 100 | type: String, 101 | }, 102 | age: { 103 | type: SimpleSchema.Integer, 104 | }, 105 | }); 106 | 107 | export const PlayersCollection = createCollection({ 108 | name: 'players', 109 | schema: PlayerSchema, 110 | }); 111 | ``` 112 | 113 | ### Composers 114 | 115 | Example creating a way to paginate the fetch of data using `composers` 116 | 117 | ```javascript 118 | import { createCollection } from 'meteor/quave:collections'; 119 | 120 | const LIMIT = 7; 121 | export const paginable = collection => 122 | Object.assign({}, collection, { 123 | getPaginated({ selector, options = {}, paginationAction }) { 124 | const { skip, limit } = paginationAction || { skip: 0, limit: LIMIT }; 125 | const items = this.find(selector, { 126 | ...options, 127 | skip, 128 | limit, 129 | }).fetch(); 130 | const total = this.find(selector).count(); 131 | const nextSkip = skip + limit; 132 | const previousSkip = skip - limit; 133 | 134 | return { 135 | items, 136 | pagination: { 137 | total, 138 | totalPages: parseInt(total / limit, 10) + (total % limit > 0 ? 1 : 0), 139 | currentPage: 140 | parseInt(skip / limit, 10) + (skip % limit > 0 ? 1 : 0) + 1, 141 | ...(nextSkip < total ? { next: { skip: nextSkip, limit } } : {}), 142 | ...(previousSkip >= 0 143 | ? { previous: { skip: previousSkip, limit } } 144 | : {}), 145 | }, 146 | }; 147 | }, 148 | }); 149 | 150 | export const StoresCollection = createCollection({ 151 | name: 'stores', 152 | composers: [paginable], 153 | }); 154 | 155 | // This probably will come from the client, using Methods, REST, or GraphQL 156 | // const paginationAction = {skip: XXX, limit: YYY}; 157 | 158 | const { items, pagination } = StoresCollection.getPaginated({ 159 | selector: { 160 | ...(search ? { name: { $regex: search, $options: 'i' } } : {}), 161 | }, 162 | options: { sort: { updatedAt: -1 } }, 163 | paginationAction, 164 | }); 165 | ``` 166 | 167 | A different example, a little bit more complex, overriding a few methods of the original collection in order to implement a soft removal (this example only works in `quave:collections@1.1.0` or later). 168 | 169 | ```javascript 170 | import { createCollection } from 'meteor/quave:collections'; 171 | 172 | const toSelector = (filter) => { 173 | if (typeof filter === 'string') { 174 | return { _id: filter }; 175 | } 176 | return filter; 177 | }; 178 | 179 | const filterOptions = (options = {}) => { 180 | if (options.ignoreSoftRemoved) { 181 | return {}; 182 | } 183 | return { isRemoved: { $ne: true } }; 184 | }; 185 | 186 | export const softRemoval = (collection) => { 187 | const originalFind = collection.find.bind(collection); 188 | const originalFindOne = collection.findOne.bind(collection); 189 | const originalUpdate = collection.update.bind(collection); 190 | const originalRemove = collection.remove.bind(collection); 191 | return Object.assign({}, collection, { 192 | find(selector, options) { 193 | return originalFind( 194 | { 195 | ...toSelector(selector), 196 | ...filterOptions(options), 197 | }, 198 | options 199 | ); 200 | }, 201 | findOne(selector, options) { 202 | return originalFindOne( 203 | { 204 | ...toSelector(selector), 205 | ...filterOptions(options), 206 | }, 207 | options 208 | ); 209 | }, 210 | remove(selector, options = {}) { 211 | if (options.hardRemove) { 212 | return originalRemove(selector); 213 | } 214 | return originalUpdate( 215 | { 216 | ...toSelector(selector), 217 | }, 218 | { 219 | $set: { 220 | ...(options.$set || {}), 221 | isRemoved: true, 222 | removedAt: new Date(), 223 | }, 224 | }, 225 | { multi: true } 226 | ); 227 | }, 228 | }); 229 | }; 230 | export const SourcesCollection = createCollection({ 231 | name: 'sources', 232 | composers: [softRemoval], 233 | }); 234 | 235 | // usage example 236 | SourcesCollection.remove({ _id: 'KEFemSmeZ9EpNfkcH' }); // this will use the soft removal, it means, this setting `isRemoved` to true 237 | SourcesCollection.remove({ _id: 'KEFemSmeZ9EpNfkcH' }, { hardRemove: true }); // this will remove in the database 238 | 239 | ``` 240 | 241 | ### Options 242 | Second argument for the default [collections constructor](https://docs.meteor.com/api/collections.html#Mongo-Collection). 243 | Example defining a transform function. 244 | ```javascript 245 | const transform = doc => ({ 246 | ...doc, 247 | get user() { 248 | return Meteor.users.findOne(this.userId); 249 | }, 250 | }); 251 | 252 | export const PlayersCollection = createCollection({ 253 | name: 'players', 254 | schema, 255 | options: { 256 | transform, 257 | }, 258 | }); 259 | ``` 260 | 261 | ### Meteor.users 262 | 263 | Extending Meteor.users, also using `collection`, `helpers`, `composers`, `apply`. 264 | 265 | You can use all these options also with `name` instead of `instance`. 266 | 267 | ```javascript 268 | import {createCollection} from 'meteor/quave:collections'; 269 | 270 | export const UsersCollection = createCollection({ 271 | instance: Meteor.users, 272 | schema: UserTypeDef, 273 | collection: { 274 | isAdmin(userId) { 275 | const user = userId && this.findOne(userId, {fields: {profiles: 1}}); 276 | return ( 277 | user && user.profiles && user.profiles.includes(UserProfile.ADMIN.name) 278 | ); 279 | }, 280 | }, 281 | helpers: { 282 | toPaymentGatewayJson() { 283 | return { 284 | country: 'us', 285 | external_id: this._id, 286 | name: this.name, 287 | type: 'individual', 288 | email: this.email, 289 | }; 290 | }, 291 | }, 292 | composers: [paginable], 293 | apply(coll) { 294 | coll.after.insert(userAfterInsert(coll), {fetchPrevious: false}); 295 | coll.after.update(userAfterUpdate); 296 | }, 297 | }); 298 | ``` 299 | 300 | ## Limitations 301 | 302 | - You can't apply `type` and `typeFields` when you inform an instance of a MongoDB collection, usually you only use an instance for `Meteor.users`. In this case I would recommend you to don't add fields with custom types to the users documents. 303 | 304 | - If you want to use your objects from the database also in the client but you don't use your whole collection in client (you are not using Mini Mongo) you need to instantiate your type also in the client, you can do this importing your type and calling `register`. This is important to register it as an EJSON type. 305 | 306 | ### License 307 | 308 | MIT 309 | -------------------------------------------------------------------------------- /collections.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Mongo } from 'meteor/mongo'; 3 | 4 | import { getSettings } from 'meteor/quave:settings'; 5 | 6 | import { CustomTypeCollection } from './CustomTypeCollection'; 7 | 8 | const PACKAGE_NAME = 'quave:collections'; 9 | const settings = getSettings({ packageName: PACKAGE_NAME }); 10 | 11 | const { isServerOnly, isVerbose } = settings; 12 | 13 | /** 14 | * Copied from recompose https://github.com/acdlite/recompose/blob/master/src/packages/recompose/compose.js#L1 15 | * @param funcs 16 | * @returns {*|(function(*): *)} 17 | */ 18 | const compose = (...funcs) => 19 | funcs.reduce( 20 | (a, b) => (...args) => a(b(...args)), 21 | arg => arg 22 | ); 23 | 24 | const getDbCollection = ({ name, definition, helpers, instance, options }) => { 25 | if (definition) { 26 | if (instance) { 27 | throw new Error("dbCollection is already defined, type can't be applied"); 28 | } 29 | 30 | return CustomTypeCollection.createTypedCollection(name, definition, { 31 | helpers, 32 | }); 33 | } 34 | let dbCollection = instance; 35 | if (!dbCollection) { 36 | dbCollection = new Mongo.Collection(name, options); 37 | } 38 | if (helpers && Object.keys(helpers).length) { 39 | if (!dbCollection.helpers) { 40 | throw new Error( 41 | "You need to add this package https://github.com/dburles/meteor-collection-helpers to use 'helpers'" 42 | ); 43 | } 44 | dbCollection.helpers(helpers); 45 | } 46 | return dbCollection; 47 | }; 48 | 49 | export const createCollection = ({ 50 | definition, 51 | name: nameParam, 52 | schema: schemaParam, 53 | collection = {}, 54 | helpers = {}, 55 | apply = null, 56 | composers = [], 57 | instance = null, 58 | options = {}, 59 | }) => { 60 | try { 61 | 62 | const schema = definition ? definition.toSimpleSchema() : schemaParam; 63 | const name = definition ? definition.pluralNameCamelCase : nameParam; 64 | if (isVerbose) { 65 | console.log(`${PACKAGE_NAME} ${name} settings`, settings); 66 | } 67 | 68 | if (!name && !instance) { 69 | throw new Error( 70 | "The option 'name' is required, unless you are using the option 'instance' that is not your case :)." 71 | ); 72 | } 73 | if (Meteor.isClient && isServerOnly) { 74 | throw new Error( 75 | 'Collections are not allowed in the client, you can disable this changing the setting `isServerOnly`' 76 | ); 77 | } 78 | const dbCollection = getDbCollection({ 79 | name, 80 | definition, 81 | helpers, 82 | instance, 83 | options, 84 | }); 85 | 86 | if (apply) { 87 | apply(dbCollection); 88 | } 89 | 90 | Object.assign(dbCollection, collection); 91 | Object.assign(dbCollection, compose(...composers)(dbCollection)); 92 | if (schema) { 93 | if (!dbCollection.attachSchema) { 94 | throw new Error( 95 | "attachSchema function is not present in your collection so you can't use 'schema' option, use https://github.com/Meteor-Community-Packages/meteor-collection2 if you want to have it." 96 | ); 97 | } 98 | dbCollection.attachSchema(schema); 99 | } 100 | dbCollection.definition = definition; 101 | return dbCollection; 102 | } catch (e) { 103 | console.error( 104 | `An error has happened when your collection${ 105 | nameParam ? ` "${nameParam}"` : '' 106 | } was being created.`, 107 | e 108 | ); 109 | throw e; 110 | } 111 | }; 112 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'quave:collections', 3 | version: '1.1.0', 4 | summary: 'Utility package to create Meteor collections in a standard way', 5 | git: 'https://github.com/quavedev/collections', 6 | }); 7 | 8 | Package.onUse(function(api) { 9 | api.versionsFrom('1.10.2'); 10 | 11 | api.use('ecmascript'); 12 | 13 | api.use('mongo'); 14 | api.use('ejson'); 15 | 16 | api.use('quave:settings@1.0.0'); 17 | 18 | api.mainModule('collections.js'); 19 | }); 20 | --------------------------------------------------------------------------------