UNPKG

graphile-build

Version:

Build a GraphQL schema from plugins

674 lines 28.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Behavior = void 0; exports.joinBehaviors = joinBehaviors; exports.joinResolvedBehaviors = joinResolvedBehaviors; exports.isValidBehaviorString = isValidBehaviorString; const grafast_1 = require("grafast"); const graphile_config_1 = require("graphile-config"); const NULL_BEHAVIOR = Object.freeze({ behaviorString: "", stack: Object.freeze([]), }); const getEntityBehaviorHooks = (plugin, type) => { const val = plugin.schema?.entityBehavior; if (!val) return val; // These might not all be hooks, some might be strings. We need to convert the strings into hooks. const result = Object.create(null); for (const [entityType, rhs] of Object.entries(val)) { const isArrayOfStrings = Array.isArray(rhs) && rhs.every((t) => typeof t === "string"); if (isArrayOfStrings || typeof rhs === "string") { if (type === "inferred") { const hook = { provides: ["default"], before: ["inferred"], callback: isArrayOfStrings ? (behavior) => [...rhs, behavior] : (behavior) => [rhs, behavior], }; result[entityType] = hook; } else { // noop } } else if (Array.isArray(rhs)) { if (type === "inferred") { throw new Error(`Behavior of '${entityType}' was specified as an array, but not every element of the array was a string (plugin: ${plugin.name})`); } else { // noop } } else if (typeof rhs === "function") { if (type === "inferred") { const hook = { provides: ["inferred"], after: ["default"], callback: rhs, }; result[entityType] = hook; } else { // noop } } else if (rhs.inferred || rhs.override) { const hook = rhs[type]; if (hook) { result[entityType] = hook; } } else { console.warn(`Plugin ${plugin.name} is using a deprecated or unsupported form of 'plugin.schema.entityBehavior[${JSON.stringify(entityType)}]' definition - if this is an object it should have only the keys 'inferred' and/or 'override'. This changed in graphile-build@5.0.0-beta.25.`); const rhsAny = rhs; if (rhsAny.callback) { if (rhsAny.provides?.includes?.("override")) { if (type === "override") { result[entityType] = rhsAny; } } else { if (type === "inferred") { result[entityType] = rhsAny; } } } } } return result; }; const getEntityBehaviorInferredHooks = (plugin) => { return getEntityBehaviorHooks(plugin, "inferred"); }; const getEntityBehaviorOverrideHooks = (plugin) => { return getEntityBehaviorHooks(plugin, "override"); }; class Behavior { constructor(resolvedPreset, build) { this.behaviorEntityTypes = []; this._defaultBehaviorByEntityTypeCache = new Map(); this.resolvedPreset = resolvedPreset; this.build = build; this.behaviorRegistry = Object.create(null); this.behaviorEntities = Object.create(null); this.registerEntity("string"); // This will be overwritten in freeze this.globalDefaultBehavior = NULL_BEHAVIOR; } /** * Forbid registration of more global behavior defaults, behavior entity types, etc. */ freeze() { const { resolvedPreset, build } = this; const plugins = (0, graphile_config_1.sortedPlugins)(resolvedPreset.plugins); const allEntities = new Set(); for (const plugin of plugins) { const r = plugin.schema?.behaviorRegistry; if (r?.add) { for (const [key, spec] of Object.entries(r.add)) { const behaviorString = key; if (!this.behaviorRegistry[behaviorString]) { this.behaviorRegistry[behaviorString] = { entities: Object.create(null), }; } const { description } = spec; for (const entityType of spec.entities) { allEntities.add(entityType); if (!this.behaviorRegistry[behaviorString].entities[entityType]) { this.behaviorRegistry[behaviorString].entities[entityType] = { registeredBy: [ { description, pluginName: plugin.name, }, ], }; } else { this.behaviorRegistry[behaviorString].entities[entityType].registeredBy.push({ description, pluginName: plugin.name, }); } } } } } const pluginDefaultBehavior = this.resolveBehavior(null, NULL_BEHAVIOR, plugins.map((p) => [ `${p.name}.schema.globalBehavior`, p.schema?.globalBehavior, ]), build); const defaultBehaviorFromPreset = this.resolvedPreset.schema?.defaultBehavior ?? ""; this.globalDefaultBehavior = { behaviorString: `${pluginDefaultBehavior.behaviorString} ${defaultBehaviorFromPreset}`, stack: [ ...pluginDefaultBehavior.stack, { source: "preset.schema.defaultBehavior", prefix: "", suffix: defaultBehaviorFromPreset, }, ], }; (0, graphile_config_1.orderedApply)(plugins, getEntityBehaviorInferredHooks, (hookName, hookFn, plugin) => { const entityType = hookName; if (!this.behaviorEntities[entityType]) { this.registerEntity(entityType); } const t = this.behaviorEntities[entityType]; t.inferredBehaviorCallbacks.push([ `${plugin.name}.schema.entityBehavior.${entityType}.inferred`, hookFn, ]); }); (0, graphile_config_1.orderedApply)(plugins, getEntityBehaviorOverrideHooks, (hookName, hookFn, plugin) => { const entityType = hookName; if (!this.behaviorEntities[entityType]) { this.registerEntity(entityType); } const t = this.behaviorEntities[entityType]; t.overrideBehaviorCallbacks.push([ `${plugin.name}.schema.entityBehavior.${entityType}.override`, hookFn, ]); }); Object.freeze(this); Object.freeze(this.behaviorEntities); for (const key of Object.keys(this.behaviorEntities)) { Object.freeze(this.behaviorEntities[key]); } return this; } registerEntity(entityType) { if (entityType === "string") { this.stringMatches = stringMatches; this.stringBehavior = (str) => str; return; } this.behaviorEntityTypes.push(entityType); this.behaviorEntities[entityType] = { behaviorStrings: Object.create(null), inferredBehaviorCallbacks: [], overrideBehaviorCallbacks: [], listCache: new Map(), inferredCache: new Map(), overrideCache: new Map(), fullCache: new Map(), }; this[`${entityType}Matches`] = (entity, filter) => this.entityMatches(entityType, entity, filter); this[`${entityType}Behavior`] = (entity) => this.getBehaviorForEntity(entityType, entity).behaviorString; } assertEntity(entityType) { if (entityType === "string") { throw new Error(`Runtime behaviors cannot be attached to strings, please use 'behavior.stringMatches' directly.`); } if (!this.behaviorEntities[entityType]) { throw new Error(`Behavior entity type '${entityType}' is not registered; known entity types: ${Object.keys(this.behaviorEntities).join(", ")}`); } } // TODO: would be great if this could return `{deprecationReason: string}` too... /** * @param entityType - the type of entity whose behaviour is being checked * @param entity - the entity whose behaviour is being checked * @param filter - the behavior the plugin specifies */ entityMatches(entityType, entity, filter) { if (!this.behaviorRegistry[filter]) { // DIAGNOSTIC: enable for all filters /* console.warn( `Behavior '${filter}' is not registered; please be sure to register it within a plugin via \`plugin.schema.behaviorRegistry.add[${JSON.stringify( filter, )}] = { description: "...", entities: [${JSON.stringify( entityType, )}] }\`.`, ); // Register it so we don't see this warning again */ this.behaviorRegistry[filter] = { entities: { [entityType]: { registeredBy: [], }, }, }; } else if (!Object.entries(this.behaviorRegistry).some(([bhv, { entities }]) => entities[entityType] && stringMatches(bhv, filter))) { console.warn(`Behavior '${filter}' is not registered for entity type '${entityType}'; it's only expected to be used with '${Object.keys(this.behaviorRegistry[filter].entities).join("', '")}'; if this usage is valid, register it within a plugin with \`plugin.schema.behaviorRegistry.add[${JSON.stringify(filter)}] = { description: "...", entities: [${JSON.stringify(entityType)}] }\`.`); // Register it so we don't see this warning again this.behaviorRegistry[filter].entities[entityType] = { registeredBy: [], }; } const finalString = this.getBehaviorForEntity(entityType, entity).behaviorString; return stringMatches(finalString, filter); } /** * Given the entity `rawEntity` of type `entityType`, this function will * return the final behavior string for this entity, respecting all the * global and entity-specific behaviors. * * This is expensive to compute, so we cache it. * * **IMPORTANT**: `rawEntity` should be a fixed value so that the cache can be * reused. If it is a dynamic value (e.g. it's a combination of multiple * entities) then you should represent it as a tuple and we'll automatically * cache that. */ getBehaviorForEntity(entityType, rawEntity) { this.assertEntity(entityType); const behaviorEntity = this.behaviorEntities[entityType]; const { fullCache: cache } = behaviorEntity; const entity = Array.isArray(rawEntity) ? getCachedEntity(behaviorEntity.listCache, rawEntity) : rawEntity; const existing = cache.get(entity); if (existing !== undefined) { return existing; } const inferredBehavior = this.getInferredBehaviorForEntity(entityType, rawEntity); const overrideBehavior = this.getOverrideBehaviorForEntity(entityType, rawEntity); const behavior = this.getPreferencesAppliedBehaviors(entityType, inferredBehavior, overrideBehavior); cache.set(entity, behavior); return behavior; } getInferredBehaviorForEntity(entityType, rawEntity) { this.assertEntity(entityType); const behaviorEntity = this.behaviorEntities[entityType]; const { inferredCache: cache, inferredBehaviorCallbacks: callbacks } = behaviorEntity; const entity = Array.isArray(rawEntity) ? getCachedEntity(behaviorEntity.listCache, rawEntity) : rawEntity; const existing = cache.get(entity); if (existing !== undefined) { return existing; } const behavior = this.resolveBehavior(entityType, this.globalDefaultBehavior, callbacks, entity, this.build); cache.set(entity, behavior); return behavior; } getOverrideBehaviorForEntity(entityType, rawEntity) { this.assertEntity(entityType); const behaviorEntity = this.behaviorEntities[entityType]; const { overrideCache: cache, overrideBehaviorCallbacks: callbacks } = behaviorEntity; const entity = Array.isArray(rawEntity) ? getCachedEntity(behaviorEntity.listCache, rawEntity) : rawEntity; const existing = cache.get(entity); if (existing !== undefined) { return existing; } const behavior = this.resolveBehavior(entityType, NULL_BEHAVIOR, callbacks, entity, this.build); cache.set(entity, behavior); return behavior; } getCombinedBehaviorForEntities(entityType, sources) { // First; ensure that `entityType` is the last key in sources const keys = Object.keys(sources); if (keys[keys.length - 1] !== entityType || !sources[entityType]) { throw new Error(`The order of keys in 'sources' is significant; you must ensure that '${entityType}' is the last key in 'sources' so that it has highest precedence`); } const inferredBehaviors = keys.map((key) => this.getInferredBehaviorForEntity(key, sources[key])); const overrideBehaviors = keys.map((key) => this.getOverrideBehaviorForEntity(key, sources[key])); const behavior = this.getPreferencesAppliedBehaviors(entityType, joinResolvedBehaviors(inferredBehaviors), joinResolvedBehaviors(overrideBehaviors)); return behavior; } getPreferencesAppliedBehaviors(entityType, inferredBehavior, overrideBehavior) { const defaultBehavior = this.getDefaultBehaviorFor(entityType); const inferredBehaviorWithPreferencesApplied = multiplyBehavior(defaultBehavior, inferredBehavior.behaviorString, entityType); const behaviorString = joinBehaviors([ inferredBehaviorWithPreferencesApplied, overrideBehavior.behaviorString, ]); const behavior = { stack: [ ...inferredBehavior.stack, { source: `__ApplyBehaviors_${entityType}__`, prefix: "", suffix: `-* ${inferredBehaviorWithPreferencesApplied}`, }, ...overrideBehavior.stack, ], behaviorString, toString() { return behaviorString; }, }; return behavior; } /** @deprecated Please use entityMatches or stringMatches instead */ matches(localBehaviorSpecsString, filter, defaultBehavior = "") { let err; try { throw new Error("Deprecated call happened here"); } catch (e) { err = e; } const stackText = err.stack; if (!warned.has(stackText)) { warned.add(stackText); console.error(`[DEPRECATION WARNING] Something in your application is using build.behavior.matches; it should be using build.behavior.pgCodecMatches / etc instead. This API will be removed before the v5.0.0 release. ${stackText}`); } const specString = Array.isArray(localBehaviorSpecsString) ? localBehaviorSpecsString.join(" ") : localBehaviorSpecsString; const finalBehaviorSpecsString = `${defaultBehavior} ${this.globalDefaultBehavior} ${specString ?? ""}`; return stringMatches(finalBehaviorSpecsString, filter); } parseBehaviorString(behaviorString) { return parseSpecs(behaviorString); } parseScope(filter) { return parseScope(filter); } resolveBehavior(entityType, initialBehavior, // Misnomer; also allows strings or nothings callbacks, ...args) { let behaviorString = initialBehavior.behaviorString; const stack = [...initialBehavior.stack]; for (const [source, rawG] of callbacks) { const oldBehavior = behaviorString; const g = typeof rawG === "string" ? [rawG] : rawG; if (Array.isArray(g)) { if (g.length === 0) continue; if (behaviorString === "") { behaviorString = g.join(" "); } else { behaviorString = g.join(" ") + " " + behaviorString; } } else if (typeof g === "function") { const newBehavior = g(oldBehavior, ...args); if (!newBehavior.includes(oldBehavior)) { throw new Error(`${source} callback must return a list that contains the current (passed in) behavior in addition to any other behaviors you wish to set.`); } if (Array.isArray(newBehavior)) { behaviorString = joinBehaviors(newBehavior); } else { behaviorString = newBehavior; } } const i = behaviorString.indexOf(oldBehavior); const prefix = behaviorString.substring(0, i); const suffix = behaviorString.substring(i + oldBehavior.length); if (prefix !== "" || suffix !== "") { this.validateBehavior(entityType, source, prefix); this.validateBehavior(entityType, source, suffix); stack.push({ source, prefix, suffix }); } } return { stack, behaviorString: behaviorString, toString() { return behaviorString; }, }; } validateBehavior(entityType, source, behaviorString) { try { this.parseBehaviorString(behaviorString); if (!entityType) { return; } } catch (e) { throw new Error(`Failed parsing behavior string ${JSON.stringify(behaviorString)} from '${source}': ${e.message}`); } } getDefaultBehaviorFor(entityType) { if (!this._defaultBehaviorByEntityTypeCache.has(entityType)) { const supportedBehaviors = new Set(); for (const [behaviorString, spec] of Object.entries(this.behaviorRegistry)) { /* * This is `true` because of inheritance (e.g. unique inherits from * resource inherits from codec); it causes a headache if we factor it * in. */ const applyBehaviorsFromAllEntities = true; if (spec.entities[entityType] || applyBehaviorsFromAllEntities) { const parts = behaviorString.split(":"); const l = parts.length; for (let i = 0; i < l; i++) { const subparts = parts.slice(i, l); // We need to add all of the parent behaviors, e.g. `foo:bar:baz` // should also add `bar:baz` and `baz` supportedBehaviors.add(subparts.join(":")); } } } // TODO: scope this on an entity basis const defaultBehaviors = this.globalDefaultBehavior; const behaviorString = ([...supportedBehaviors].sort().join(" ") + " " + defaultBehaviors.behaviorString).trim(); this._defaultBehaviorByEntityTypeCache.set(entityType, behaviorString); return behaviorString; } return this._defaultBehaviorByEntityTypeCache.get(entityType); } } exports.Behavior = Behavior; /** * Parses a scope like `query:resource:connection:filter` into it's constituent parts. * * @internal */ function parseScope(scopeString) { return scopeString.split(":"); } /** * Parses a behaviorSpecs string like `+list -connection -list:filter` into a * list of BehaviorSpecs. * * @internal */ function parseSpecs(behaviorSpecsString) { const fragments = behaviorSpecsString.split(/\s+/); const specs = []; for (const fragment of fragments) { if (fragment === "") continue; // `+` is implicit const [pm, rest] = /^[+-]/.test(fragment) ? [fragment.slice(0, 1), fragment.slice(1)] : ["+", fragment]; const positive = pm === "+"; const scope = parseScope(rest); if (scope[scope.length - 1] === "create") { throw new Error(`'create' behavior is forbidden; did you mean 'insert'?`); } specs.push({ positive, scope }); } return specs; } /** * Returns true if `filterScope` can be matched by `specifiedScope`. * * If `filterScope` contains an `*` then we return true if any possible * `filterScope` can be matched by `specifiedScope` in a positive fashion. * * @param specifiedScope - the scope the user entered, e.g. from `+query:*:filter` * @param filterScope - the scope the plugin says we're in, e.g. from `query:resource:connection:filter` * * @internal */ function scopeMatches(specifiedScope, filterScope, // We only need to know positive or negative in case the filter contains an `*`. // This is because if you filter for '*:foo' against '+bar:foo -baz:foo' then // we should skip the negative (`-baz:foo`) because we only need _one_ match, // not *every* match. positive) { if (specifiedScope.length > filterScope.length) { return false; } // `specifiedScope` is effectively prepended with `*:*:*:` as many times as // necessary to make it the same length as `filterScope`. In actuality we do // it more efficiently. const filterScopeTrimmed = specifiedScope.length === filterScope.length ? filterScope : filterScope.slice(filterScope.length - specifiedScope.length); // Loop through each entry, if it doesn't match then return false. for (let i = 0, l = filterScopeTrimmed.length; i < l; i++) { const rule = specifiedScope[i]; const filter = filterScopeTrimmed[i]; if (filter === "*" && rule !== "*" && !positive) { return false; } if (rule === "*" || filter === "*") { continue; } if (rule !== filter) { return false; } } return true; } function joinBehaviors(strings) { let str = ""; for (const string of strings) { if (string != null && string !== "") { if (grafast_1.isDev && !isValidBehaviorString(string)) { throw new Error(`'${string}' is not a valid behavior string`); } if (str === "") { str = string; } else { str += " " + string; } } } return str; } function joinResolvedBehaviors(behaviors) { const stack = behaviors.flatMap((b) => b.stack); const behaviorString = joinBehaviors(behaviors.flatMap((b) => b.behaviorString)); const b = { behaviorString, stack, }; return b; } function getCachedEntity(listCache, entity) { const nList = listCache.get(entity.length); if (!nList) { const list = [entity]; listCache.set(entity.length, list); return entity; } for (const entry of nList) { if ((0, grafast_1.arraysMatch)(entry, entity)) { return entry; } } nList.push(entity); return entity; } const warned = new Set(); function stringMatches(behaviorString, filter) { const specs = parseSpecs(behaviorString); const filterScope = parseScope(filter); if (filterScope[filterScope.length - 1] === "create") { throw new Error(`'create' filter scope is forbidden; did you mean 'insert'?`); } // Loop backwards through the specs for (let i = specs.length - 1; i >= 0; i--) { const { positive, scope } = specs[i]; if (scopeMatches(scope, filterScope, positive)) { return positive; } } return undefined; } /** * We're strict with this because we want to be able to expand this in future. * For example I want to allow `@behavior all some` to operate the same as * `@behavior all\n@behavior some`. I also want to be able to add * `@behavior -all` to remove a previously enabled behavior. */ function isValidBehaviorString(behavior) { return (typeof behavior === "string" && /^\s*([+-]?([a-zA-Z](_?[a-zA-Z0-9]+)|\*)(:([a-zA-Z](_?[a-zA-Z0-9]+)|\*))*)(\s+([+-]?([a-zA-Z](_?[a-zA-Z0-9]+)|\*)(:([a-zA-Z](_?[a-zA-Z0-9]+)|\*))*))*\s*$/.test(behavior)); } const warnedBehaviors = []; /* * 1. Take each behavior from inferred * 2. Find the matching behaviors from preferences * 3. Output for the behavior a list of behaviors formed by combining the * matching behaviors. The result needs to remain at least as constrained as * it already is. * * For example: * - Preferences: "-* +resource:list -resource:connection +query:resource:connection -query:resource:list" * - AKA: turn everything off, use connections at the root, lists elsewhere * - Inferred: "+connection +list" * - Split to ["+connection", "+list"] * - For "+connection" (which is equivalent to `+*:*:*:connection`, remember): * - "-*" becomes "-connection" (needs to remain at least as constrained as it already is) * - "-resource:connection" matches and is kept * - "+query:resource:connection" matches and is kept * - all other behaviors ignored (don't match) * - For "+list": * - "-*" becomes "-list" * - "+resource:list" kept * - "-query:resource:list" kept * - all others don't match * - Result: concatenate these: * - "-connection -resource:connection +query:resource:connection -list +resource:list -query:resource:list" */ function multiplyBehavior(preferences, inferred, entityType) { const pref = parseSpecs(preferences); const inf = parseSpecs(inferred); const result = inf.flatMap((infEntry) => { const final = []; nextPref: for (const prefEntry of pref) { // If it matches; new scope must be at least as constrainted as old scope const newScope = []; const l = Math.max(prefEntry.scope.length, infEntry.scope.length); // Does it match? Loop backwards through scope keys ensuring matches for (let i = 1; i <= l; i++) { const infScope = i <= infEntry.scope.length ? infEntry.scope[infEntry.scope.length - i] : "*"; const prefScope = i <= prefEntry.scope.length ? prefEntry.scope[prefEntry.scope.length - i] : "*"; const match = infScope === "*" || prefScope === "*" || infScope == prefScope; if (!match) { // No match! Skip to next preference continue nextPref; } // There was a match; ensure we're suitably constrained const scopeText = infScope == "*" ? prefScope : infScope; newScope.unshift(scopeText); } // If we get here, it must match; add our new behavior final.push({ scope: newScope, positive: prefEntry.positive && infEntry.positive, }); } if (final.length === 0) { const behavior = infEntry.scope.join(":"); if (!warnedBehaviors.includes(behavior)) { warnedBehaviors.push(behavior); console.warn(`No matches for behavior '${behavior}' - please ensure that this behavior is registered for entity type '${entityType}'`); } } return final; }); const behaviorString = result .map((r) => `${r.positive ? "" : "-"}${r.scope.join(":")}`) .join(" "); return behaviorString; } //# sourceMappingURL=behavior.js.map