powershell/plugins/plugin-create-inline-properties.ts (323 lines of code) (raw):
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { codeModelSchema, Property, CodeModel, ObjectSchema, ConstantSchema, GroupSchema, isObjectSchema, SchemaType, GroupProperty, ParameterLocation, Operation, Parameter, ImplementationLocation, OperationGroup, Request, SchemaContext } from '@autorest/codemodel';
import { getPascalIdentifier, removeSequentialDuplicates, pascalCase, fixLeadingNumber, deconstruct, selectName, EnglishPluralizationService, serialize } from '@azure-tools/codegen';
import { length, values, } from '@azure-tools/linq';
import { AutorestExtensionHost as Host, Session, startSession } from '@autorest/extension-base';
import { CommandOperation, CommandType } from '../utils/command-operation';
import { PwshModel } from '../utils/PwshModel';
import { ModelState } from '../utils/model-state';
import { ExternalDocumentation } from '../utils/components';
import { VirtualParameter } from '../utils/command-operation';
import { VirtualProperty, getAllProperties, getAllPublicVirtualProperties, getMutability } from '../utils/schema';
import { resolveParameterNames } from '../utils/resolve-conflicts';
import { OperationType } from '../utils/command-operation';
function getPluralizationService(): EnglishPluralizationService {
const result = new EnglishPluralizationService();
result.addWord('Database', 'Databases');
result.addWord('database', 'databases');
result.addWord('Premise', 'Premises');
result.addWord('premise', 'premises');
return result;
}
type State = ModelState<PwshModel>;
export function singularize(word: string): string {
return getPluralizationService().singularize(word);
}
function getCombinedDescription(rawDescription: string, externalDocsUrl?: string): string {
let description = rawDescription ?? '';
if (!!externalDocsUrl && !!externalDocsUrl.trim()) {
description = description.concat(` Please visit external url ${externalDocsUrl} to get more information.`);
}
return description;
}
function getNameOptions(typeName: string, components: Array<string>) {
const result = new Set<string>();
// add a variant for each incrementally inclusive parent naming scheme.
for (let i = 0; i < length(components); i++) {
const subset = pascalCase([...removeSequentialDuplicates(components.slice(-1 * i, length(components)))]);
result.add(subset);
}
// add a second-to-last-ditch option as <typename>.<name>
result.add(pascalCase([...removeSequentialDuplicates([...fixLeadingNumber(deconstruct(typeName)), ...deconstruct(components.last)])]));
return [...result.values()];
}
function createVirtualProperties(schema: ObjectSchema, stack: Array<string>, threshold: number, conflicts: Array<string>) {
// Some properties should be removed are wrongly kept as null and need to clean them
if (schema.properties) {
schema.properties = schema.properties.filter(each => each);
}
// dolauli
// owned: all properties(obj & nonobj) in the schema,
// inherited: Properties from parents,
// inlined: for obj properties, flatten them to children,
// did we already inline this object
if (schema.language.default.inline === 'yes') {
return true;
}
if (schema.language.default.inline === 'no') {
return false;
}
// this is bad. This would happen when we have a circular reference in the tree.
// dolauli curious in which case this will happen, got it to use no-inline to skip inline and avoid circular reference
if (schema.language.default.inline === 'inprogress') {
let text = (`Note: during processing of '${schema.language.default.name}' a circular reference has been discovered.`);
text += '\n In order to proceed, you must add a directive to indicate which model you want to not inline.\n';
text += '\ndirective:';
text += '\n- no-inline: # choose ONE of these models to disable inlining';
for (const each of stack) {
text += (`\n - ${each} `);
}
text += '\n';
conflicts.push(text);
/* `directive:
- no-inline:
- MyModel
- YourModel
- HerModel
` */
// `, and we're skipping inlining.\n ${stack.join(' => ')}`);
// mark it as 'not-inlining'
schema.language.default.inline = 'no';
return false;
}
// ok, set to in progress now.
schema.language.default.inline = 'inprogress';
// virutual property set.
const virtualProperties = schema.language.default.virtualProperties = {
owned: new Array<VirtualProperty>(),
inherited: new Array<VirtualProperty>(),
inlined: new Array<VirtualProperty>(),
};
// First we should run thru the properties in parent classes and create inliners for each property they have.
// dolauli handle properties in parents
for (const parentSchema of values(schema.parents?.immediate)) {
// make sure that the parent is done.
// Guess parent should always be an object.
if (!isObjectSchema(parentSchema))
continue;
createVirtualProperties(parentSchema, [...stack, `${schema.language.default.name}`], threshold, conflicts);
const parentProperties = parentSchema.language.default.virtualProperties || {
owned: [],
inherited: [],
inlined: [],
};
// now we go thru the parent's virutal properties and create our own copies
for (const virtualProperty of [...parentProperties.inherited, ...parentProperties.inlined, ...parentProperties.owned]) {
// make sure that we have a list of shared owners of this property.
virtualProperty.sharedWith = virtualProperty.sharedWith || [virtualProperty];
// we are just copying over theirs to ours.
const inheritedProperty = {
name: virtualProperty.name,
property: virtualProperty.property,
private: virtualProperty.private,
nameComponents: virtualProperty.nameComponents,
nameOptions: virtualProperty.nameOptions,
accessViaProperty: virtualProperty,
accessViaMember: virtualProperty,
accessViaSchema: parentSchema,
originalContainingSchema: virtualProperty.originalContainingSchema,
description: virtualProperty.description,
alias: [],
create: virtualProperty.create,
update: virtualProperty.update,
read: virtualProperty.read,
readOnly: virtualProperty.readOnly,
required: virtualProperty.required,
sharedWith: virtualProperty.sharedWith,
};
// add it to the list of virtual properties that share this property.
virtualProperty.sharedWith.push(inheritedProperty);
// add it to this class.
virtualProperties.inherited.push(inheritedProperty);
}
}
// dolauli figure out object properties and non object properties in this class
const [objectProperties, nonObjectProperties] = values(schema.properties).bifurcate(each =>
!schema.language.default['skip-inline'] && // if this schema is marked skip-inline, none can be inlined, treat them all as straight properties.
!each.schema.language.default['skip-inline'] && // if the property schema is marked skip-inline, then it should not be processed either.
each.schema.type === SchemaType.Object && // is it an object
getAllProperties(each.schema).length > 0 // does it have properties (or inherit properties)
);
// run thru the properties in this class.
// dolauli handle properties in this class
for (const property of objectProperties) {
const mutability = getMutability(property);
const propertyName = property.language.default.name;
// for each object member, make sure that it's inlined it's children that it can.
createVirtualProperties(<ObjectSchema>property.schema, [...stack, `${schema.language.default.name}`], threshold, conflicts);
// this happens if there is a circular reference.
// this means that this class should not attempt any inlining of that property at all .
// dolauli pay attention to the condition check
const isDict = property.schema.type === SchemaType.Dictionary || (<ObjectSchema>property.schema).parents?.immediate?.find((s) => s.type === SchemaType.Dictionary);
const canInline =
(!property.schema.language.default['skip-inline']) &&
(!<ObjectSchema>property.schema.language.default.byReference) &&
(!isDict) &&
(<ObjectSchema>property.schema).language.default.inline === 'yes';
// the target has properties that we can inline
const virtualChildProperties = property.schema.language.default.virtualProperties || {
owned: [],
inherited: [],
inlined: [],
};
const allNotRequired = values(getAllPublicVirtualProperties()).all(each => !each.property.language.default.required);
const childCount = length(virtualChildProperties.owned) + length(virtualChildProperties.inherited) + length(virtualChildProperties.inlined);
if (canInline && (property.language.default.required || allNotRequired) && (childCount < threshold || propertyName === 'properties')) {
// if the child property is low enough (or it's 'properties'), let's create virtual properties for each one.
// create a private property for the inlined ones to use.
const combinedDescription = getCombinedDescription(property.language.default.description, property.schema?.externalDocs?.url);
property.language.default.description = combinedDescription;
const privateProperty = {
name: getPascalIdentifier(propertyName),
propertySchema: schema,
property,
nameComponents: [getPascalIdentifier(propertyName)],
nameOptions: getNameOptions(schema.language.default.name, [propertyName]),
private: true,
description: property.summary || '',
originalContainingSchema: schema,
alias: [],
required: property.required || property.language.default.required,
};
virtualProperties.owned.push(privateProperty);
for (const inlinedProperty of [...virtualChildProperties.inherited, ...virtualChildProperties.owned]) {
// child properties are be inlined without prefixing the name with the property name
// unless there is a collision, in which case, we have to resolve
// (scan back from the far right)
// deeper child properties should be inlined with their parent's name
// ie, this.[properties].owner.name should be this.ownerName
const proposedName = getPascalIdentifier(`${propertyName === 'properties' || /*objectProperties.length === 1*/ propertyName === 'error' ? '' : pascalCase(fixLeadingNumber(deconstruct(propertyName)).map(each => singularize(each)))} ${inlinedProperty.name}`);
const components = [...removeSequentialDuplicates([propertyName, ...inlinedProperty.nameComponents])];
let readonly = inlinedProperty.readOnly || property.readOnly;
const create = mutability.create && inlinedProperty.create && !readonly;
const update = mutability.update && inlinedProperty.update && !readonly;
const read = mutability.read && inlinedProperty.read;
readonly = readonly || (read && !update && !create);
virtualProperties.inlined.push({
name: proposedName,
property: inlinedProperty.property,
private: inlinedProperty.private,
nameComponents: components,
nameOptions: getNameOptions(inlinedProperty.property.schema.language.default.name, components),
accessViaProperty: privateProperty,
accessViaMember: inlinedProperty,
accessViaSchema: schema,
originalContainingSchema: schema,
description: inlinedProperty.description,
alias: [],
create: create,
update: update,
read: read,
readOnly: readonly,
required: inlinedProperty.required && privateProperty.required,
});
}
for (const inlinedProperty of [...virtualChildProperties.inlined]) {
// child properties are be inlined without prefixing the name with the property name
// unless there is a collision, in which case, we have to resolve
// (scan back from the far right)
// deeper child properties should be inlined with their parent's name
// ie, this.[properties].owner.name should be this.ownerName
const proposedName = getPascalIdentifier(inlinedProperty.name);
let readonly = inlinedProperty.readOnly || property.readOnly;
const create = mutability.create && inlinedProperty.create && !readonly;
const update = mutability.update && inlinedProperty.update && !readonly;
const read = mutability.read && inlinedProperty.read;
readonly = readonly || (read && !update && !create);
const components = [...removeSequentialDuplicates([propertyName, ...inlinedProperty.nameComponents])];
virtualProperties.inlined.push({
name: proposedName,
property: inlinedProperty.property,
private: inlinedProperty.private,
nameComponents: components,
nameOptions: getNameOptions(inlinedProperty.property.schema.language.default.name, components),
accessViaProperty: privateProperty,
accessViaMember: inlinedProperty,
accessViaSchema: schema,
originalContainingSchema: schema,
description: inlinedProperty.description,
alias: [],
create: create,
update: update,
read: read,
readOnly: readonly,
required: inlinedProperty.required && privateProperty.required
});
}
} else {
// otherwise, we're not below the threshold, and we should treat this as a non-inlined property
nonObjectProperties.push(property);
}
}
for (const property of nonObjectProperties) {
const name = getPascalIdentifier(<string>property.language.default.name);
// this is not something that has properties,
// so we don't need to do any inlining
// however, we can add it to our list of virtual properties
// so that our consumers can get it.
const mutability = getMutability(property);
const combinedDescription = getCombinedDescription(property.language.default.description, property.schema?.externalDocs?.url);
property.language.default.description = combinedDescription;
virtualProperties.owned.push({
name,
property,
nameComponents: [name],
nameOptions: [name],
description: property.summary || '',
originalContainingSchema: schema,
alias: [],
create: mutability.create && !property.readOnly,
update: mutability.update && !property.readOnly,
read: mutability.read,
readOnly: property.readOnly || (mutability.read && !mutability.create && !mutability.update),
required: property.required || property.language.default.required
});
}
// resolve name collisions.
const allProps = [...virtualProperties.owned, ...virtualProperties.inherited, ...virtualProperties.inlined];
const inlined = new Map<string, number>();
for (const each of allProps) {
// track number of instances of a given name.
inlined.set(each.name, (inlined.get(each.name) || 0) + 1);
}
const usedNames = new Set(inlined.keys());
for (const each of virtualProperties.inlined.sort((a, b) => length(a.nameOptions) - length(b.nameOptions))) {
const ct = inlined.get(each.name);
if (ct && ct > 1) {
// console.error(`Fixing collision on name ${each.name} #${ct} `);
each.name = selectName(each.nameOptions, usedNames);
}
}
schema.language.default.inline = 'yes';
return true;
}
function createVirtualParameters(operation: CommandOperation) {
// dolauli expand body parameter
// for virtual parameters, there are two keys, operation and body
const virtualParameters = {
operation: new Array<VirtualParameter>(),
body: new Array<VirtualParameter>()
};
const dropBodyParameter = !!operation.details.default.dropBodyParameter;
// loop thru the parameters of the command operation, and if there is a body parameter, expand it if necessary.
for (const parameter of values(operation.parameters)) {
if (parameter.details.default.constantValue) {
// this parameter has a constant value -- SKIP IT
continue;
}
// dolauli fromhost and apiversion are not exposed, this if block looks useless
if (parameter.details.default.fromHost || parameter.details.default.apiversion) {
// handled in the generator right now. Not exposed to the user directly.
continue;
}
if (dropBodyParameter && parameter.details.default.isBodyParameter) {
// the client will make a hidden body parameter for this, and we're expected to fill it.
const vps = parameter.schema.language.default.virtualProperties;
if (vps) {
for (const virtualProperty of [...vps.inherited, ...vps.owned, ...vps.inlined]) {
// dolauli add virtual parameter for virtual property
if (virtualProperty.private || virtualProperty.readOnly || virtualProperty.property.readOnly || virtualProperty.property.language.default.constantValue !== undefined || virtualProperty.property.language.default.HeaderProperty === 'Header') {
// private or readonly properties aren't needed as parameters.
continue;
}
// Add support for x-ms-mutability
if (operation.operationType === OperationType.Create && !(<VirtualProperty>virtualProperty).create) {
continue;
} else if (operation.operationType === OperationType.Update && !(<VirtualProperty>virtualProperty).update) {
continue;
}
virtualParameters.body.push({
name: virtualProperty.name,
description: virtualProperty.property.language.default.description,
nameOptions: virtualProperty.nameOptions,
required: parameter.required ? shouldBeRequired(operation, virtualProperty) : false,
schema: virtualProperty.property.schema,
origin: virtualProperty,
alias: []
});
}
}
} else {
// dolauli if not drop body or not body parameter add it to operation
const combinedDescription = getCombinedDescription(parameter.schema.language.default.description, parameter.schema.externalDocs?.url);
parameter.schema.language.default.description = combinedDescription;
virtualParameters.operation.push({
name: parameter.details.default.name,
nameOptions: [parameter.details.default.name],
description: parameter.details.default.description,
required: parameter.details.default.isBodyParameter ? true : parameter.required,
schema: parameter.schema,
origin: parameter,
alias: []
});
}
}
resolveParameterNames([], virtualParameters);
// dolauli see operation.details.default.virtualParameters
operation.details.default.virtualParameters = virtualParameters;
}
export async function createVirtuals(state: State): Promise<PwshModel> {
/*
A model class should provide inlined properties for anything in a property called properties
Classes that have $THRESHOLD number of properties should be inlined.
Individual models can change the $THRESHOLD for generate
*/
const threshold = await state.getValue('inlining-threshold', 24);
const conflicts = new Array<string>();
for (const schema of values(state.model.schemas.objects)) {
// did we already inline this objecct
if (schema.language.default.inlined) {
continue;
}
// we have an object, let's process it.
createVirtualProperties(schema, new Array<string>(), threshold, conflicts);
}
if (length(conflicts) > 0) {
// dolauli need to figure out how inline-properties is used in README.md
state.error('You have one or more circular references in your model, you must add configuration entries to specify which models won\'t be inlined.', ['inline-properties']);
for (const each of conflicts) {
state.error(each, ['circular reference']);
}
throw new Error('Circular references exists, must mark models as `no-inline`');
}
//dolauli update operations under commands
for (const operation of values(state.model.commands.operations)) {
createVirtualParameters(operation);
}
return state.model;
}
function shouldBeRequired(operation: CommandOperation, virtualProperty: VirtualProperty): boolean {
const shouldBeOptional = operation.commandType === CommandType.GetPut || operation.commandType === CommandType.ManagedIdentityUpdate;
if (!shouldBeOptional) {
return virtualProperty.required;
}
if (!virtualProperty.read && (virtualProperty.create || virtualProperty.update)) {
return virtualProperty.required;
}
return false;
}
export async function createInlinedPropertiesPlugin(service: Host) {
//const session = await startSession<PwshModel>(service, {}, codeModelSchema);
//const result = tweakModelV2(session);
const state = await new ModelState<PwshModel>(service).init();
await service.writeFile({ filename: 'code-model-v4-create-virtual-properties-v2.yaml', content: serialize(await createVirtuals(state)), sourceMap: undefined, artifactType: 'code-model-v4' });
//return processCodeModel(createVirtuals, service, 'create-virtual-properties-v2');
}