This is super super early Proof of Concept stage =]
Last active
August 1, 2022 21:59
-
-
Save taxilian/1272a705765261ffb8e865b79954fb45 to your computer and use it in GitHub Desktop.
Tool to watch for .schema.json files and compile them as mongodb interfaces using json-schema-to-typescript, also proxy wrapper to use this for creating doc/model thingies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Db } from './dbInit'; | |
| import {Consumer as consumerType, jsonSchema as consumerSchema} from './schemas/consumer'; | |
| import {getDocWrapper } from './MongoProxy/Document'; | |
| import { getCollection } from './MongoProxy/CollectionConfig'; | |
| /** | |
| * Define the MongoDB collection and schema for the Consumer model. | |
| */ | |
| export const ConsumerCollection = getCollection<consumerType>({ | |
| db: Db, | |
| schema: consumerSchema as any, | |
| name: 'consumers', | |
| indexes: [ | |
| [{uniqueSlug: 1}, {unique: true}], | |
| ] | |
| }); | |
| // In production this should probably only happen intentionally, not automatically | |
| ConsumerCollection.ensureIndexes(); | |
| // const ConsumerMethods = makeMethods(ConsumerCollection, ); | |
| const ConsumerDoc = getDocWrapper(ConsumerCollection, { | |
| getSchemaName() { | |
| return this.name + '/' + this.uniqueSlug; | |
| }, | |
| }); | |
| export type ConsumerDoc = ReturnType<typeof ConsumerDoc>; | |
| /** | |
| * Used if you want a model with custom methods -- returns an object which has | |
| * everything from the ConsumerCollection object plus has a `wrap` function | |
| * to wrap a DocType into the proxied DocType and also any methods here | |
| */ | |
| export const ConsumerModel = ConsumerCollection.makeModel(ConsumerDoc, { | |
| async findConsumerBySlug(slug: string) { | |
| const doc = await ConsumerCollection.findOne({uniqueSlug: slug}); | |
| if (doc) return this.wrap(doc); | |
| return null; | |
| } | |
| }); | |
| async function test() { | |
| const doc = await ConsumerModel.findConsumerBySlug('test'); | |
| doc!.getSchemaName(); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Collection wrapper, used as an easy way to configure things and a place to put simple helpers, | |
| // also the basis for making a "Model" object | |
| import * as mongodb from 'mongodb'; | |
| import { JSONSchema4 } from 'json-schema'; | |
| import camelCase from 'lodash/camelCase'; | |
| interface IndexSpecification { | |
| [key: string]: mongodb.IndexDirection; | |
| } | |
| type IndexDefinition = IndexSpecification | |
| | [IndexSpecification] | |
| | [IndexSpecification, mongodb.CreateIndexesOptions]; | |
| const collectionFindFns = [ | |
| 'find', 'findOne', 'findOneAndDelete', 'findOneAndReplace', | |
| ] as const; | |
| const collectionFnsToMap = [ | |
| 'aggregate', 'bulkWrite', 'count', 'countDocuments', | |
| 'deleteMany', 'deleteOne', 'distinct', 'drop', 'dropIndexes', | |
| 'estimatedDocumentCount', 'indexExists', 'indexInformation', | |
| 'insertOne', 'insertMany', 'replaceOne', 'options', 'stats', 'updateMany', 'updateOne', | |
| 'watch', 'initializeOrderedBulkOp', 'initializeUnorderedBulkOp', | |
| ...collectionFindFns, | |
| ] as const; | |
| type collectionFnsToMap = typeof collectionFnsToMap[number]; | |
| type CollectionWrapper<DocType> = { | |
| collection: mongodb.Collection<DocType>; | |
| } & Pick<mongodb.Collection<DocType>, collectionFnsToMap>; | |
| interface CollectionWrapperConstructor<DocType> { | |
| new(collection: mongodb.Collection<DocType>): CollectionWrapper<DocType>; | |
| } | |
| function _wrapCollection<DocType>(this: CollectionWrapper<DocType>, collection: mongodb.Collection<DocType>) { | |
| this.collection = collection; | |
| } | |
| function getCollectionWrapper<DocType>() { | |
| return _wrapCollection as any as CollectionWrapperConstructor<DocType>; | |
| } | |
| export interface CollectionOptions { | |
| db: mongodb.Db; | |
| indexes?: IndexDefinition[]; | |
| /** | |
| * The json schema to use for validation. | |
| */ | |
| schema: JSONSchema4; | |
| /** | |
| * If not specified, the collection name is auto detected from the json schema title | |
| */ | |
| name?: string; | |
| collectionOpts?: mongodb.CollectionOptions; | |
| } | |
| type ModelFunctions<CollectionConfig> = { | |
| [key: string]: (this: CollectionConfig, ...args: any) => any; | |
| }; | |
| class CollectionConfigImpl<DocType> { | |
| private _collection: mongodb.Collection<DocType>; | |
| get db() { return this.opts.db; } | |
| get schema() { return this.opts.schema; } | |
| get collection() { return this._collection; } | |
| constructor(protected opts: CollectionOptions) { | |
| const {db, schema} = opts; | |
| let collectionName = opts.name; | |
| if (!collectionName) { | |
| collectionName = camelCase(schema.title ?? ''); | |
| if (!collectionName) { | |
| throw new Error("Collection name or titled schema is required"); | |
| } | |
| } | |
| this._collection = db.collection<DocType>(collectionName, { | |
| ...(opts.collectionOpts ?? {}), | |
| }); | |
| } | |
| ensureIndexes() { | |
| const {collection, opts} = this; | |
| const {indexes} = opts; | |
| const promiseList = indexes?.map(async index => { | |
| if (Array.isArray(index)) { | |
| if (index.length === 2) return collection.createIndex(index[0], index[1]); | |
| else return collection.createIndex(index[0]); | |
| } else return collection.createIndex(index); | |
| }) ?? []; | |
| return Promise.all(promiseList); | |
| } | |
| makeModel<Wrap extends (doc?: DocType) => DocType, T extends ModelFunctions<this & {wrap: Wrap}>>(wrapFn: Wrap, fnObject: T) { | |
| const topModel: any = function CollectionModel() {} | |
| topModel.prototype = this; | |
| const midModel: any = function SpecializedModel() {} | |
| midModel.prototype = new topModel(); | |
| for (const fn of Object.keys(fnObject)) { | |
| midModel.prototype[fn] = fnObject[fn]; | |
| } | |
| midModel.prototype.wrap = wrapFn; | |
| const outModel: this & T & {wrap: Wrap} = new midModel(); | |
| return outModel; | |
| } | |
| } | |
| export type CollectionConfig<DocType> = CollectionConfigImpl<DocType> & Pick<mongodb.Collection<DocType>, collectionFnsToMap>; | |
| // Add collection functions to the object even if typescript doesn't like it | |
| for (const fnName of collectionFnsToMap) { | |
| (<CollectionConfig<any>>CollectionConfigImpl.prototype)[fnName] = function(this: CollectionConfig<any>, ...args: any[]) { return (<any>this.collection)[fnName](...args); }; | |
| } | |
| /** | |
| * Creates a collection config object which wraps the collection and provides many of the same methods; | |
| * you can still access the original collection and underlying database with the `collection` and | |
| * `db` properties | |
| * @param opts The options to use for the collection | |
| * @returns The collection config object | |
| */ | |
| export function getCollection<DocType>(opts: CollectionOptions): CollectionConfig<DocType> { | |
| // Typescript doens't know we augmented the object prototype, so it thinks this is invalid -- it is | |
| // WRONG WRONG WRONG! | |
| return new CollectionConfigImpl<DocType>(opts) as any; | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Document wrapper | |
| import * as mongodb from 'mongodb'; | |
| import { ObjectId, BSONTypeAlias } from 'mongodb'; | |
| import { JSONSchema4 } from 'json-schema'; | |
| import { coerceTypeIfNeeded } from './schemaChecks'; | |
| import { CollectionConfig } from './CollectionConfig'; | |
| const magicKey = '__$_obj' as const; | |
| type ProxiedDocOf<DocType> = DocType & { | |
| [magicKey]?: DocType; | |
| } | |
| type DocumentMethod<DocType> = (this: DocumentMethods<DocType> & DocType, ...args: any[]) => any; | |
| export type DocumentMethodObject<DocType> = Record<string, DocumentMethod<DocType>>; | |
| export type DocumentWrapper<DocType, Methods extends DocumentMethodObject<DocType>> = DocType & Methods; | |
| function getDatabaseMethods<DocType extends Record<string, any>>(schema: JSONSchema4, collection: mongodb.Collection<DocType>) { | |
| return { | |
| toJSON(this: ProxiedDocOf<DocType>) { return this.__$_obj; }, | |
| toObject(this: ProxiedDocOf<DocType>) { return this.__$_obj; }, | |
| save(this: ProxiedDocOf<DocType>, options: mongodb.FindOneAndReplaceOptions = {}) { | |
| const target = this.__$_obj!; | |
| return collection.findOneAndReplace({ _id: target._id }, target, { | |
| upsert: true, | |
| ...options, | |
| }); | |
| }, | |
| }; | |
| } | |
| type DocumentMethods<DocType extends Record<string, any>> = ReturnType<typeof getDatabaseMethods<DocType>>; | |
| function getProxyHandler(schema: JSONSchema4, strict = false, dbMethods: DocumentMethods<any>) { | |
| const getters: {[key: string]: (target: any) => any} = {}; | |
| const setters: {[key: string]: (target: any, value: any) => void} = {}; | |
| for (const key of Object.keys(schema.properties ?? {})) { | |
| const schemaDef = schema.properties![key]; | |
| const type = (<BSONTypeAlias>schemaDef.bsonType) || schemaDef.type; | |
| if (type) { | |
| if (key === '_id' && type === 'objectId') { | |
| getters[key] = (target: any) => { | |
| if (!target._id) { | |
| target._id = new ObjectId(); | |
| } | |
| return coerceTypeIfNeeded(target._id, 'objectId'); | |
| }; | |
| } else { | |
| getters[key] = target => coerceTypeIfNeeded(target[key], type); | |
| } | |
| setters[key] = (target, value) => { | |
| target[key] = coerceTypeIfNeeded(value, type); | |
| }; | |
| } | |
| } | |
| const ownKeys: string[] = [ | |
| ...Object.keys(dbMethods), | |
| ...Object.keys(schema?.properties ?? {}), | |
| ]; | |
| const handler: ProxyHandler<Record<any, any>> = { | |
| get: (target, prop: string, receiver) => { | |
| if (prop === magicKey) { return target; } | |
| if (prop in getters) return getters[prop](target); | |
| if (prop in dbMethods) { | |
| return dbMethods[prop as keyof DocumentMethods<any>]; | |
| } | |
| if (strict) { return void 0; } | |
| return target[prop]; | |
| }, | |
| set: (target, prop: string, value, receiver) => { | |
| if (prop in setters) { | |
| setters[prop](target, value); | |
| return true; | |
| } | |
| return false; | |
| }, | |
| has: (target, key: string) => { | |
| return key in dbMethods || key in getters || key in setters; | |
| }, | |
| ownKeys: (target) => { | |
| return ownKeys; | |
| }, | |
| }; | |
| return handler; | |
| } | |
| // type OnlyObject<T extends any> = T extends infer R ? R extends object ? R : never : never; | |
| // type MyFromSchema<T extends JSONSchema> = T extends infer R ? OnlyObject<FromSchema<R, MongoDBTranslator>> : never; | |
| export function getDocWrapper<DocType extends Record<string, any>, | |
| methodObj extends DocumentMethodObject<DocType>, | |
| >(collCfg: CollectionConfig<DocType>, | |
| methods: methodObj = {} as methodObj, | |
| ) { | |
| type docMethods = DocumentMethods<DocType>; | |
| let {schema, collection} = collCfg; | |
| const dbMethods = { | |
| ...methods, | |
| ...getDatabaseMethods<DocType>(schema, collection), | |
| }; | |
| const modelProxyHandler = getProxyHandler(schema, true, dbMethods); | |
| return (doc: DocType = {} as any) => { | |
| const proxied = new Proxy<DocType>(doc, modelProxyHandler); | |
| return proxied as DocumentWrapper<DocType, methodObj & docMethods>; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Helpers for doing type checks with json schema, mongodb. I'd love to find a better json schema | |
| // library which could do these checks for us and tell us what types to coerce as needed, but havne't found one yet | |
| import { ObjectId, BSONTypeAlias } from 'mongodb'; | |
| import { JSONSchema4TypeName, JSONSchema4 } from 'json-schema' | |
| type AnyValidType = JSONSchema4TypeName | BSONTypeAlias; | |
| export function neverError(v: never) { | |
| return new Error(`Unexpected type: ${v}`); | |
| } | |
| export const typeCheck = <const>{ | |
| any: (value: any) => true, | |
| string: (value: any) => typeof value === 'string', | |
| int: (value: any) => typeof value === 'number' && Math.floor(value) === value, | |
| integer: (value: any) => typeof value === 'number' && Math.floor(value) === value, | |
| number: (value: any) => typeof value === 'number', | |
| long: (value: any) => typeof value === 'bigint', | |
| double: (value: any) => typeof value === 'number', | |
| decimal: (value: any) => typeof value === 'number', | |
| regex: (value: any) => value instanceof RegExp, | |
| object: (value: any) => typeof value === 'object', | |
| array: (value: any) => Array.isArray(value), | |
| boolean: (value: any) => typeof value === 'boolean', | |
| bool: (value: any) => typeof value === 'boolean', | |
| null: (value: any) => value === null, | |
| undefined: (value: any) => value === void 0, | |
| date: (value: any) => value instanceof Date, | |
| binData: (value: any) => value instanceof Buffer, | |
| objectId: (value: any) => value instanceof ObjectId, | |
| javascript: (value: any) => false, | |
| symbol: (value: any) => false, | |
| javascriptWithScope: (value: any) => false, | |
| minKey: (value: any) => false, | |
| maxKey: (value: any) => false, | |
| dbPointer: (value: any) => false, | |
| timestamp: (value: any) => false, | |
| } | |
| export const coerceType = <const>{ | |
| any: (value: any) => value, | |
| string: (value: any) => typeof(value) === 'undefined' ? value : String(value), | |
| int: (value: any) => typeof(value) === 'undefined' ? value : Math.floor(value), | |
| integer: (value: any) => typeof(value) === 'undefined' ? value : Math.floor(value), | |
| number: (value: any) => typeof(value) === 'undefined' ? value : Number(value), | |
| long: (value: any) => typeof(value) === 'undefined' ? value : BigInt(value), | |
| double: (value: any) => typeof(value) === 'undefined' ? value : parseFloat(String(value)), | |
| decimal: (value: any) => typeof(value) === 'undefined' ? value : parseFloat(String(value)), | |
| regex: (value: any) => typeof(value) === 'undefined' ? value : new RegExp(value), | |
| object: (value: any) => value, | |
| array: (value: any) => value, | |
| boolean: (value: any) => Boolean(value), | |
| bool: (value: any) => Boolean(value), | |
| null: (value: any) => null, | |
| undefined: (value: any) => void 0, | |
| date: (value: any) => typeof(value) === 'undefined' ? value : new Date(value), | |
| binData: (value: any) => typeof(value) === 'undefined' ? value : Buffer.from(value), | |
| objectId: (value: any, raw?: boolean) => typeof(value) === 'undefined' ? value : new ObjectId(value), | |
| javascript: (value: any) => { throw new Error("Not implemented") }, | |
| symbol: (value: any) => { throw new Error("Not implemented") }, | |
| javascriptWithScope: (value: any) => { throw new Error("Not implemented") }, | |
| minKey: (value: any) => { throw new Error("Not implemented") }, | |
| maxKey: (value: any) => { throw new Error("Not implemented") }, | |
| dbPointer: (value: any) => { throw new Error("Not implemented") }, | |
| timestamp: (value: any) => { throw new Error("Not implemented") }, | |
| } | |
| export function isCorrectType(value: any, type: AnyValidType | AnyValidType[]) { | |
| type = Array.isArray(type) ? type : [type]; | |
| const checkFns = type.map(t => typeCheck[t] || neverError); | |
| return checkFns.some(fn => fn(value)); | |
| } | |
| export function coerceTypeIfNeeded(value: unknown, type: AnyValidType | AnyValidType[], raw = false) { | |
| if (isCorrectType(value, type)) { | |
| return value; | |
| } else { | |
| const firstType = Array.isArray(type) ? type[0] : type; | |
| const coerceFn = coerceType[firstType] || neverError; | |
| return coerceFn(value, raw); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "type": "object", | |
| "title": "Consumer", | |
| "properties": { | |
| "_id": { | |
| "bsonType": "objectId", | |
| "tsType": "ObjectId" | |
| }, | |
| "uniqueSlug": { "type": "string" }, | |
| "name": { "type": "string" }, | |
| "rank": { "type": "string" }, | |
| "perms": { | |
| "type": "array", | |
| "items": { | |
| "enum": ["read", "admin"] | |
| } | |
| } | |
| }, | |
| "required": ["_id", "uniqueSlug", "name", "perms"], | |
| "additionalProperties": false | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * This file was automatically generated by json-schema-to-typescript | |
| * from `src/model/schemas/consumer.schema.json`. | |
| * | |
| * To modify, update `src/model/schemas/consumer.schema.json` | |
| * and run `node bin/buildSchemas.js` in the project root to regenerate this file. | |
| */ | |
| import {ObjectId} from "mongodb"; | |
| export interface Consumer { | |
| _id: ObjectId; | |
| uniqueSlug: string; | |
| name: string; | |
| rank?: string; | |
| perms: ("read" | "admin")[]; | |
| } | |
| import jsonSchema from './consumer.schema.json'; | |
| export {jsonSchema}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const path = require('path'); | |
| const fs = require('fs'); | |
| const chokidar = require('chokidar'); | |
| const jsonToTs = require('json-schema-to-typescript'); | |
| const rootPath = path.join(process.cwd(), process.argv[2]); | |
| const projectRoot = path.resolve(path.join(__dirname, '..')); | |
| const scriptPath = path.relative(projectRoot, __filename); | |
| const bannerComment = ` | |
| /** | |
| * This file was automatically generated by json-schema-to-typescript | |
| * from \`{{FILENAME}}\`. | |
| * | |
| * To modify, update \`{{FILENAME}}\` | |
| * and run \`node ${scriptPath}\` in the project root to regenerate this file. | |
| */ | |
| import {ObjectId} from 'mongodb'; | |
| `; | |
| console.log("SCHEMA BUILD -- watching for schema files in " + rootPath); | |
| const watcher = chokidar.watch(path.join(rootPath, '**', '*.schema.json'), { | |
| depth: 10, // max depth just in case | |
| }); | |
| async function processFile(filePath, event) { | |
| const outPath = filePath.replace('.schema.json', '.ts'); | |
| console.log(`${event}: Processing ${filePath} -> ${outPath}`); | |
| try { | |
| const schemaFile = path.basename(filePath); | |
| const schemaPath = path.relative(projectRoot, filePath); | |
| let compiled = await jsonToTs.compileFromFile(filePath, { | |
| enableConstEnums: true, | |
| bannerComment, | |
| }); | |
| compiled = compiled.replace(/\{\{FILENAME\}\}/g, schemaPath); | |
| compiled += ` | |
| import jsonSchema from './${schemaFile}'; | |
| export {jsonSchema}; | |
| `; | |
| await fs.promises.writeFile(outPath, compiled); | |
| } catch (err) { | |
| console.error(`Error processing ${filePath}`, err); | |
| } | |
| } | |
| watcher.on('add', (path, stats) => { | |
| processFile(path, 'add'); | |
| }); | |
| watcher.on('change', (path, stats) => { | |
| processFile(path, 'change'); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment