Skip to content

Instantly share code, notes, and snippets.

@taxilian
Last active August 1, 2022 21:59
Show Gist options
  • Save taxilian/1272a705765261ffb8e865b79954fb45 to your computer and use it in GitHub Desktop.
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

State:

This is super super early Proof of Concept stage =]

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();
}
// 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;
}
// 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>;
}
}
// 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);
}
}
{
"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 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};
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