diff --git a/src/tools/flavored-plan-generator.ts b/src/tools/flavored-plan-generator.ts new file mode 100644 index 00000000000..e7589cd65de --- /dev/null +++ b/src/tools/flavored-plan-generator.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2020 Google LLC. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * Code distributed by Google as part of this project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +import {Recipe} from '../runtime/recipe/lib-recipe.js'; +import {KotlinGenerationUtils, upperFirst, tryImport} from './kotlin-generation-utils.js'; +import {PlanGenerator} from './plan-generator.js'; +import {Dictionary} from '../utils/lib-utils.js'; + +const ktUtils = new KotlinGenerationUtils(); + +export class FlavoredPlanGenerator { + constructor(private flavoredRecipes: Dictionary, + private resolvedRecipeNames: Set, + private namespace: string, + private flavorSelector: string + ) {} + + /** Generates a Kotlin file with plan classes derived from resolved recipes. */ + async generate(): Promise { + const emptyPlanGenerator = new PlanGenerator([], this.namespace); + + const planOutline = [ + emptyPlanGenerator.fileHeader(), + ...(await this.createPlans()), + '\n', + ...(await this.createPlanSelectors()), + emptyPlanGenerator.fileFooter() + ]; + + return planOutline.join('\n'); + } + + async createPlans(): Promise { + const allPlans: string[] = []; + for (const flavor of Object.keys(this.flavoredRecipes)) { + const recipe = this.flavoredRecipes[flavor]; + const planGenerator = new PlanGenerator(recipe, this.namespace); + // const plan = (await planGenerator.createPlans()).join('\n'); + // ktUtils.indent(plan, 1), + allPlans.push( + `class ${this.flavorClass(flavor)} {`, + ` companion object {`, + ktUtils.joinWithIndents( + await planGenerator.createPlans(), + {startIndent: 0, numberOfIndents: 1}), + ' }', + `}\n`, + ); + } + return allPlans; + } + + private flavorClass(flavor: string): string { + return `${upperFirst(flavor)}Plans`; + } + + async createPlanSelectors(): Promise { + const flavors = Object.keys(this.flavoredRecipes); + const allPlans: string[] = []; + for (const recipeName of this.resolvedRecipeNames) { + allPlans.push( + await this.createSelectorExpression(recipeName, flavors)); + } + return allPlans; + } + + private async createSelectorExpression( + recipeName: string, flavors: string[]): Promise { + const planName = `${recipeName}Plan`; + return ktUtils.property(`${planName}`, async ({startIndent}) => { + return [ + `when (${this.flavorSelector}) {`, + ...flavors.map(flavor => + ktUtils.indent( + `case "${flavor}": ${this.flavorClass(flavor)}.${planName}`, + )), + '}' + ].join('\n'); + }, {delegate: 'lazy'}); + } +} diff --git a/src/tools/recipe2plan.ts b/src/tools/recipe2plan.ts index 874df889e54..e3564ee7b26 100644 --- a/src/tools/recipe2plan.ts +++ b/src/tools/recipe2plan.ts @@ -9,9 +9,12 @@ */ import {AllocatorRecipeResolver} from './allocator-recipe-resolver.js'; import {PlanGenerator} from './plan-generator.js'; +import {FlavoredPlanGenerator} from './flavored-plan-generator.js'; import {assert} from '../platform/assert-node.js'; import {encodePlansToProto} from './manifest2proto.js'; import {Manifest} from '../runtime/manifest.js'; +import {Dictionary} from '../utils/lib-utils.js'; +import {Recipe} from '../runtime/recipe/lib-recipe.js'; export enum OutputFormat { Kotlin, Proto } @@ -46,3 +49,38 @@ export async function recipe2plan( default: throw new Error('Output Format should be Kotlin or Proto'); } } + + +/** + * Generates Flavored Plans from recipes in an arcs manifest. + * + * @param path path/to/manifest.arcs + * @param format Kotlin or Proto supported. + * @param recipeFilter Optionally, target a single recipe within the manifest. + * @return Generated Kotlin code. + */ +export async function recipe2flavoredplan( + manifest: Manifest, + flavoredPolicies: Dictionary, + flavorSelector: string, + recipeFilter?: string, + salt = `salt_${Math.random()}`): Promise { + + // TODO: If no policy or one policy is specified default to recipe2plan. + const flavoredPlans : Dictionary = {}; + const resolvedRecipeNames: Set = new Set(); + for (const flavor of Object.keys(flavoredPolicies)) { + const policiesManifest = flavoredPolicies[flavor]; + let plans = await (new AllocatorRecipeResolver(manifest, salt, policiesManifest)).resolve(); + if (recipeFilter) { + plans = plans.filter(p => p.name === recipeFilter); + if (plans.length === 0) throw Error(`Recipe '${recipeFilter}' not found.`); + } + plans.forEach(recipe => resolvedRecipeNames.add(recipe.name)); + flavoredPlans[flavor] = plans; + } + + assert(manifest.meta.namespace, `Namespace is required in '${manifest.fileName}' for Kotlin code generation.`); + return new FlavoredPlanGenerator( + flavoredPlans, resolvedRecipeNames, manifest.meta.namespace, flavorSelector).generate(); +} diff --git a/src/tools/tests/goldens/flavored-plan-generator-plans.cgtest b/src/tools/tests/goldens/flavored-plan-generator-plans.cgtest new file mode 100644 index 00000000000..14c456253aa --- /dev/null +++ b/src/tools/tests/goldens/flavored-plan-generator-plans.cgtest @@ -0,0 +1,292 @@ +-----[header]----- +Kotlin Plan Generation - Flavors + +Expectations can be updated with: +$ ./tools/sigh updateCodegenUnitTests +-----[end_header]----- + +-----[name]----- +uses the same storage key for created and mapped handles +-----[input]----- +meta + namespace: com.google.test + +schema Thing { + num: Number + text: Text + ok: Text +} + +policy NumPolicy { + @maxAge(age: '2d') + @allowedRetention(medium: 'Disk', encryption: false) + from Thing access { ok, num, text } +} + +policy TextPolicy { + @maxAge(age: '2d') + @allowedRetention(medium: 'Disk', encryption: false) + from Thing access { ok, num, text } +} + +particle A + data: writes Thing +particle B + data: reads Thing {ok: Text} + +@arcId('ingestion') +recipe Ingest + h: create 'data' @persistent @ttl('2d') + A + data: writes h + +recipe Retrieve + h: map 'data' + B + data: reads h +-----[results]----- +/* ktlint-disable */ +@file:Suppress("PackageName", "TopLevelName") + +package com.google.test + +// +// GENERATED CODE -- DO NOT EDIT +// + +import arcs.core.data.* +import arcs.core.data.expression.* +import arcs.core.data.expression.Expression.* +import arcs.core.data.expression.Expression.BinaryOp.* +import arcs.core.data.Plan.* +import arcs.core.storage.StorageKeyManager +import arcs.core.util.ArcsInstant +import arcs.core.util.ArcsDuration +import arcs.core.util.BigInt + +class TestPlans { + companion object { + + val Ingest_Handle0 by lazy { + Handle( + StorageKeyManager.GLOBAL_INSTANCE.parse( + "db://5899b47e7afaed6870f78a643e30bf6695334b22@arcs/!:ingestion/handle/data" + ), + arcs.core.data.SingletonType( + arcs.core.data.EntityType( + arcs.core.data.Schema( + setOf(arcs.core.data.SchemaName("Thing")), + arcs.core.data.SchemaFields( + singletons = mapOf( + "ok" to arcs.core.data.FieldType.Text, + "num" to arcs.core.data.FieldType.Number, + "text" to arcs.core.data.FieldType.Text + ), + collections = emptyMap() + ), + "5899b47e7afaed6870f78a643e30bf6695334b22", + refinementExpression = true.asExpr(), + queryExpression = true.asExpr() + ) + ) + ), + listOf( + Annotation("persistent", emptyMap()), + Annotation("ttl", mapOf("value" to AnnotationParam.Str("2d"))) + ) + ) + } + val IngestPlan by lazy { + Plan( + listOf( + Particle( + "A", + "", + mapOf( + "data" to HandleConnection( + Ingest_Handle0, + HandleMode.Write, + arcs.core.data.SingletonType(arcs.core.data.EntityType(A_Data.SCHEMA)), + listOf( + Annotation("persistent", emptyMap()), + Annotation("ttl", mapOf("value" to AnnotationParam.Str("2d"))) + ) + ) + ) + ) + ), + listOf(Ingest_Handle0), + listOf(Annotation("arcId", mapOf("id" to AnnotationParam.Str("ingestion")))) + ) + }, + val Retrieve_Handle0 by lazy { + Handle( + StorageKeyManager.GLOBAL_INSTANCE.parse( + "db://5899b47e7afaed6870f78a643e30bf6695334b22@arcs/!:ingestion/handle/data" + ), + arcs.core.data.SingletonType( + arcs.core.data.EntityType( + arcs.core.data.Schema( + setOf(arcs.core.data.SchemaName("Thing")), + arcs.core.data.SchemaFields( + singletons = mapOf( + "ok" to arcs.core.data.FieldType.Text, + "num" to arcs.core.data.FieldType.Number, + "text" to arcs.core.data.FieldType.Text + ), + collections = emptyMap() + ), + "5899b47e7afaed6870f78a643e30bf6695334b22", + refinementExpression = true.asExpr(), + queryExpression = true.asExpr() + ) + ) + ), + emptyList() + ) + } + val RetrievePlan by lazy { + Plan( + listOf( + Particle( + "B", + "", + mapOf( + "data" to HandleConnection( + Retrieve_Handle0, + HandleMode.Read, + arcs.core.data.SingletonType(arcs.core.data.EntityType(B_Data.SCHEMA)), + emptyList() + ) + ) + ) + ), + listOf(Retrieve_Handle0), + emptyList() + ) + } + + } +} + +class ProdPlans { + companion object { + + val Ingest_Handle0 by lazy { + Handle( + StorageKeyManager.GLOBAL_INSTANCE.parse( + "db://5899b47e7afaed6870f78a643e30bf6695334b22@arcs/!:ingestion/handle/data" + ), + arcs.core.data.SingletonType( + arcs.core.data.EntityType( + arcs.core.data.Schema( + setOf(arcs.core.data.SchemaName("Thing")), + arcs.core.data.SchemaFields( + singletons = mapOf( + "ok" to arcs.core.data.FieldType.Text, + "num" to arcs.core.data.FieldType.Number, + "text" to arcs.core.data.FieldType.Text + ), + collections = emptyMap() + ), + "5899b47e7afaed6870f78a643e30bf6695334b22", + refinementExpression = true.asExpr(), + queryExpression = true.asExpr() + ) + ) + ), + listOf( + Annotation("persistent", emptyMap()), + Annotation("ttl", mapOf("value" to AnnotationParam.Str("2d"))) + ) + ) + } + val IngestPlan by lazy { + Plan( + listOf( + Particle( + "A", + "", + mapOf( + "data" to HandleConnection( + Ingest_Handle0, + HandleMode.Write, + arcs.core.data.SingletonType(arcs.core.data.EntityType(A_Data.SCHEMA)), + listOf( + Annotation("persistent", emptyMap()), + Annotation("ttl", mapOf("value" to AnnotationParam.Str("2d"))) + ) + ) + ) + ) + ), + listOf(Ingest_Handle0), + listOf(Annotation("arcId", mapOf("id" to AnnotationParam.Str("ingestion")))) + ) + }, + val Retrieve_Handle0 by lazy { + Handle( + StorageKeyManager.GLOBAL_INSTANCE.parse( + "db://5899b47e7afaed6870f78a643e30bf6695334b22@arcs/!:ingestion/handle/data" + ), + arcs.core.data.SingletonType( + arcs.core.data.EntityType( + arcs.core.data.Schema( + setOf(arcs.core.data.SchemaName("Thing")), + arcs.core.data.SchemaFields( + singletons = mapOf( + "ok" to arcs.core.data.FieldType.Text, + "num" to arcs.core.data.FieldType.Number, + "text" to arcs.core.data.FieldType.Text + ), + collections = emptyMap() + ), + "5899b47e7afaed6870f78a643e30bf6695334b22", + refinementExpression = true.asExpr(), + queryExpression = true.asExpr() + ) + ) + ), + emptyList() + ) + } + val RetrievePlan by lazy { + Plan( + listOf( + Particle( + "B", + "", + mapOf( + "data" to HandleConnection( + Retrieve_Handle0, + HandleMode.Read, + arcs.core.data.SingletonType(arcs.core.data.EntityType(B_Data.SCHEMA)), + emptyList() + ) + ) + ) + ), + listOf(Retrieve_Handle0), + emptyList() + ) + } + + } +} + + + +val IngestPlan by lazy { + when (getFlavor()) { + case "test": TestPlans.IngestPlan + case "prod": ProdPlans.IngestPlan + } +} +val RetrievePlan by lazy { + when (getFlavor()) { + case "test": TestPlans.RetrievePlan + case "prod": ProdPlans.RetrievePlan + } +} + +-----[end]----- diff --git a/src/tools/tests/recipe2plan-codegen-test-suite.ts b/src/tools/tests/recipe2plan-codegen-test-suite.ts index 20ab0306bee..a7ce0a57ef6 100644 --- a/src/tools/tests/recipe2plan-codegen-test-suite.ts +++ b/src/tools/tests/recipe2plan-codegen-test-suite.ts @@ -13,6 +13,8 @@ import {CodegenUnitTest} from './codegen-unit-test-base.js'; import {Manifest} from '../../runtime/manifest.js'; import {AllocatorRecipeResolver} from '../allocator-recipe-resolver.js'; import {PlanGenerator} from '../plan-generator.js'; +import {recipe2flavoredplan, OutputFormat} from '../recipe2plan.js'; +import {Dictionary} from '../../utils/lib-utils.js'; export const recipe2PlanTestSuite: CodegenUnitTest[] = [ new class extends ManifestCodegenUnitTest { @@ -71,5 +73,21 @@ export const recipe2PlanTestSuite: CodegenUnitTest[] = [ const handles = recipes.map(recipe => recipe.handles).reduce((a, b) => a.concat(b)); return Promise.all(handles.map(handle => generator.createHandleVariable(handle))); } - }() + }(), + new class extends ManifestCodegenUnitTest { + constructor() { + super( + 'Kotlin Plan Generation - Flavors', + 'flavored-plan-generator-plans.cgtest' + ); + } + async computeFromManifest(manifest: Manifest) { + const policies: Dictionary = { + 'test': manifest, + 'prod': manifest, + }; + // TODO(bgogul):... + return (await recipe2flavoredplan(manifest, policies, 'getFlavor()') as string); + } + }(), ];