From 0d059260dcae579f355c55ab1bbbdb66daa0e100 Mon Sep 17 00:00:00 2001 From: Jason Heddings Date: Mon, 30 Mar 2026 16:13:59 -0600 Subject: [PATCH] feat(data): add schema types and bind command for code generation Introduces `everywhere bind` CLI command that reads Workday Extend .businessobject model files and generates TypeScript interfaces, runtime schema metadata, and typed CRUD hook wrappers. Generated output goes to the plugin's `everywhere/data/` directory. The Extend app path is saved to `everywhere/.config.json` for subsequent runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/codegen/generator.ts | 117 ++++++++++++++ cli/src/codegen/index.ts | 2 + cli/src/codegen/parser.ts | 53 +++++++ cli/src/commands/everywhere/bind.ts | 90 +++++++++++ cli/tests/codegen/generator.test.ts | 168 +++++++++++++++++++++ cli/tests/codegen/parser.test.ts | 108 +++++++++++++ cli/tests/commands/everywhere/bind.test.ts | 29 ++++ src/data/types.ts | 15 ++ src/index.ts | 2 + tests/data/types.test.ts | 88 +++++++++++ 10 files changed, 672 insertions(+) create mode 100644 cli/src/codegen/generator.ts create mode 100644 cli/src/codegen/index.ts create mode 100644 cli/src/codegen/parser.ts create mode 100644 cli/src/commands/everywhere/bind.ts create mode 100644 cli/tests/codegen/generator.test.ts create mode 100644 cli/tests/codegen/parser.test.ts create mode 100644 cli/tests/commands/everywhere/bind.test.ts create mode 100644 src/data/types.ts create mode 100644 tests/data/types.test.ts diff --git a/cli/src/codegen/generator.ts b/cli/src/codegen/generator.ts new file mode 100644 index 0000000..7be1ad9 --- /dev/null +++ b/cli/src/codegen/generator.ts @@ -0,0 +1,117 @@ +interface FieldSchema { + name: string; + type: string; + target?: string; + precision?: string; +} + +interface ModelSchema { + name: string; + label: string; + collection: string; + fields: FieldSchema[]; +} + +const HEADER = '// AUTO-GENERATED by `everywhere bind` -- do not edit manually.\n'; + +function fieldTypeToTS(field: FieldSchema): string { + switch (field.type) { + case 'TEXT': + case 'DATE': + case 'SINGLE_INSTANCE': + return 'string'; + case 'BOOLEAN': + return 'boolean'; + case 'MULTI_INSTANCE': + return 'string[]'; + default: + return 'unknown'; + } +} + +function pluralize(name: string): string { + if (name.endsWith('s')) return name + 'es'; + if (name.endsWith('y') && !/[aeiou]y$/i.test(name)) return name.slice(0, -1) + 'ies'; + return name + 's'; +} + +export function generateModels(schemas: ModelSchema[]): string { + const lines = [HEADER]; + + for (const schema of schemas) { + lines.push(`export interface ${schema.name} {`); + lines.push(' id: string;'); + for (const field of schema.fields) { + lines.push(` ${field.name}: ${fieldTypeToTS(field)};`); + } + lines.push('}'); + lines.push(''); + } + + return lines.join('\n'); +} + +export function generateSchema(schemas: ModelSchema[]): string { + const lines = [HEADER]; + + lines.push("import type { ModelSchema } from '@workday/everywhere';"); + lines.push(''); + lines.push('export const schemas: Record = {'); + + for (const schema of schemas) { + lines.push(` ${schema.name}: {`); + lines.push(` name: '${schema.name}',`); + lines.push(` label: '${schema.label}',`); + lines.push(` collection: '${schema.collection}',`); + lines.push(' fields: ['); + for (const field of schema.fields) { + const parts = [`name: '${field.name}'`, `type: '${field.type}'`]; + if (field.target) parts.push(`target: '${field.target}'`); + if (field.precision) parts.push(`precision: '${field.precision}'`); + lines.push(` { ${parts.join(', ')} },`); + } + lines.push(' ],'); + lines.push(' },'); + } + + lines.push('};'); + lines.push(''); + return lines.join('\n'); +} + +export function generateModelHooks(schema: ModelSchema): string { + const { name } = schema; + const plural = pluralize(name); + const lines = [HEADER]; + + lines.push("import { useQuery, useMutation } from '@workday/everywhere';"); + lines.push(`import type { ${name} } from './models.js';`); + lines.push(''); + lines.push(`export function use${plural}(options?: Parameters[1]) {`); + lines.push(` return useQuery<${name}>('${name}', options);`); + lines.push('}'); + lines.push(''); + lines.push(`export function use${name}(id: string) {`); + lines.push(` return useQuery<${name}>('${name}', { id });`); + lines.push('}'); + lines.push(''); + lines.push(`export function use${name}Mutation() {`); + lines.push(` return useMutation<${name}>('${name}');`); + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +export function generateIndex(schemas: ModelSchema[]): string { + const lines = [HEADER]; + + lines.push("export * from './models.js';"); + lines.push("export * from './schema.js';"); + for (const schema of schemas) { + lines.push(`export * from './${schema.name}.js';`); + } + lines.push(''); + + return lines.join('\n'); +} diff --git a/cli/src/codegen/index.ts b/cli/src/codegen/index.ts new file mode 100644 index 0000000..a4e4ba4 --- /dev/null +++ b/cli/src/codegen/index.ts @@ -0,0 +1,2 @@ +export { parseBusinessObject } from './parser.js'; +export { generateModels, generateSchema, generateModelHooks, generateIndex } from './generator.js'; diff --git a/cli/src/codegen/parser.ts b/cli/src/codegen/parser.ts new file mode 100644 index 0000000..8ad5719 --- /dev/null +++ b/cli/src/codegen/parser.ts @@ -0,0 +1,53 @@ +interface FieldSchema { + name: string; + type: string; + target?: string; + precision?: string; +} + +interface ModelSchema { + name: string; + label: string; + collection: string; + fields: FieldSchema[]; +} + +interface BusinessObjectField { + name: string; + type: string; + target?: string; + precision?: string; +} + +interface BusinessObjectDefinition { + name: string; + label: string; + defaultCollection: { name: string }; + fields: BusinessObjectField[]; +} + +export function parseBusinessObject(definition: BusinessObjectDefinition): ModelSchema { + return { + name: definition.name, + label: definition.label, + collection: definition.defaultCollection.name, + fields: definition.fields.map(parseField), + }; +} + +function parseField(field: BusinessObjectField): FieldSchema { + const schema: FieldSchema = { + name: field.name, + type: field.type as FieldSchema['type'], + }; + + if (field.target) { + schema.target = field.target; + } + + if (field.precision) { + schema.precision = field.precision; + } + + return schema; +} diff --git a/cli/src/commands/everywhere/bind.ts b/cli/src/commands/everywhere/bind.ts new file mode 100644 index 0000000..378b8ad --- /dev/null +++ b/cli/src/commands/everywhere/bind.ts @@ -0,0 +1,90 @@ +import { Args } from '@oclif/core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import EverywhereBaseCommand from './base'; +import { parseBusinessObject } from '../../codegen/parser'; +import { + generateModels, + generateSchema, + generateModelHooks, + generateIndex, +} from '../../codegen/generator'; + +const CONFIG_DIR = 'everywhere'; +const CONFIG_FILE = '.config.json'; +const OUTPUT_DIR = 'data'; + +export default class BindCommand extends EverywhereBaseCommand { + static description = + 'Generate TypeScript types and data hooks from Workday Extend business object models.'; + + static args = { + 'app-dir': Args.directory({ + description: + 'Directory containing model/ subfolder with .businessobject files. Saved for future runs.', + required: false, + }), + }; + + static flags = { + ...EverywhereBaseCommand.baseFlags, + }; + + async run(): Promise { + const { args } = await this.parse(BindCommand); + const pluginDir = await this.parsePluginDir(); + const everywhereDir = path.join(pluginDir, CONFIG_DIR); + const configPath = path.join(everywhereDir, CONFIG_FILE); + + // Resolve the app directory + let appDir: string; + + if (args['app-dir']) { + appDir = path.resolve(args['app-dir']); + // Save to config for future runs + fs.mkdirSync(everywhereDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ extend: appDir }, null, 2) + '\n'); + } else if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + appDir = path.resolve(pluginDir, config.extend); + } else { + // Fall back to plugin dir itself (model/ in the plugin) + appDir = pluginDir; + } + + // Find .businessobject files + const modelDir = path.join(appDir, 'model'); + if (!fs.existsSync(modelDir)) { + this.error(`No model/ directory found in ${appDir}`); + } + + const files = fs.readdirSync(modelDir).filter((f) => f.endsWith('.businessobject')); + if (files.length === 0) { + this.error(`No .businessobject files found in ${modelDir}`); + } + + // Parse all business objects + const schemas = files.map((file) => { + const content = fs.readFileSync(path.join(modelDir, file), 'utf-8'); + return parseBusinessObject(JSON.parse(content)); + }); + + // Generate output + const outputDir = path.join(everywhereDir, OUTPUT_DIR); + fs.mkdirSync(outputDir, { recursive: true }); + + fs.writeFileSync(path.join(outputDir, 'models.ts'), generateModels(schemas)); + fs.writeFileSync(path.join(outputDir, 'schema.ts'), generateSchema(schemas)); + fs.writeFileSync(path.join(outputDir, 'index.ts'), generateIndex(schemas)); + + for (const schema of schemas) { + fs.writeFileSync(path.join(outputDir, `${schema.name}.ts`), generateModelHooks(schema)); + } + + this.log( + `Generated types for ${schemas.length} model(s): ${schemas.map((s) => s.name).join(', ')}` + ); + this.log(`Output: ${outputDir}`); + } +} diff --git a/cli/tests/codegen/generator.test.ts b/cli/tests/codegen/generator.test.ts new file mode 100644 index 0000000..de90f7b --- /dev/null +++ b/cli/tests/codegen/generator.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import type { ModelSchema } from '../../../src/data/types.js'; +import { + generateModels, + generateSchema, + generateModelHooks, + generateIndex, +} from '../../src/codegen/generator'; + +const EMPLOYEE_SCHEMA: ModelSchema = { + name: 'Employee', + label: 'Employee', + collection: 'employees', + fields: [ + { name: 'title', type: 'TEXT' }, + { name: 'startDate', type: 'DATE', precision: 'DAY' }, + { name: 'isActive', type: 'BOOLEAN' }, + { name: 'department', type: 'SINGLE_INSTANCE', target: 'Department' }, + { name: 'tasks', type: 'MULTI_INSTANCE', target: 'Task' }, + ], +}; + +const DEPARTMENT_SCHEMA: ModelSchema = { + name: 'Department', + label: 'Department', + collection: 'departments', + fields: [{ name: 'name', type: 'TEXT' }], +}; + +describe('generateModels()', () => { + it('starts with the auto-generated comment', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toMatch(/^\/\/ AUTO-GENERATED/); + }); + + it('generates an interface for each model', () => { + const result = generateModels([EMPLOYEE_SCHEMA, DEPARTMENT_SCHEMA]); + + expect(result).toContain('export interface Employee {'); + }); + + it('includes a synthetic id field', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('id: string;'); + }); + + it('maps TEXT fields to string', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('title: string;'); + }); + + it('maps DATE fields to string', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('startDate: string;'); + }); + + it('maps BOOLEAN fields to boolean', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('isActive: boolean;'); + }); + + it('maps SINGLE_INSTANCE fields to string', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('department: string;'); + }); + + it('maps MULTI_INSTANCE fields to string[]', () => { + const result = generateModels([EMPLOYEE_SCHEMA]); + + expect(result).toContain('tasks: string[];'); + }); +}); + +describe('generateSchema()', () => { + it('starts with the auto-generated comment', () => { + const result = generateSchema([EMPLOYEE_SCHEMA]); + + expect(result).toMatch(/^\/\/ AUTO-GENERATED/); + }); + + it('imports ModelSchema from the SDK', () => { + const result = generateSchema([EMPLOYEE_SCHEMA]); + + expect(result).toContain("import type { ModelSchema } from '@workday/everywhere';"); + }); + + it('exports a schemas record', () => { + const result = generateSchema([EMPLOYEE_SCHEMA]); + + expect(result).toContain('export const schemas: Record'); + }); + + it('includes the model name as a key', () => { + const result = generateSchema([EMPLOYEE_SCHEMA]); + + expect(result).toContain('Employee: {'); + }); +}); + +describe('generateModelHooks()', () => { + it('starts with the auto-generated comment', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toMatch(/^\/\/ AUTO-GENERATED/); + }); + + it('imports useQuery and useMutation from the SDK', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toContain("import { useQuery, useMutation } from '@workday/everywhere';"); + }); + + it('imports the model type from models', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toContain("import type { Employee } from './models.js';"); + }); + + it('generates a plural query hook', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toContain('export function useEmployees('); + }); + + it('generates a singular query hook', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toContain('export function useEmployee(id: string)'); + }); + + it('generates a mutation hook', () => { + const result = generateModelHooks(EMPLOYEE_SCHEMA); + + expect(result).toContain('export function useEmployeeMutation()'); + }); +}); + +describe('generateIndex()', () => { + it('starts with the auto-generated comment', () => { + const result = generateIndex([EMPLOYEE_SCHEMA]); + + expect(result).toMatch(/^\/\/ AUTO-GENERATED/); + }); + + it('re-exports from models', () => { + const result = generateIndex([EMPLOYEE_SCHEMA]); + + expect(result).toContain("export * from './models.js';"); + }); + + it('re-exports from schema', () => { + const result = generateIndex([EMPLOYEE_SCHEMA]); + + expect(result).toContain("export * from './schema.js';"); + }); + + it('re-exports from each model hook file', () => { + const result = generateIndex([EMPLOYEE_SCHEMA, DEPARTMENT_SCHEMA]); + + expect(result).toContain("export * from './Employee.js';"); + }); +}); diff --git a/cli/tests/codegen/parser.test.ts b/cli/tests/codegen/parser.test.ts new file mode 100644 index 0000000..477443e --- /dev/null +++ b/cli/tests/codegen/parser.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { parseBusinessObject } from '../../src/codegen/parser'; + +const SIMPLE_OBJECT = { + id: 1, + name: 'DemoObj', + label: 'Demo Obj', + defaultCollection: { name: 'demoObjs', label: 'Demo Objs' }, + fields: [{ id: 1, name: 'myField1', type: 'TEXT' }], +}; + +const COMPLEX_OBJECT = { + id: 2, + name: 'WorkFromAnywhereRequest', + label: 'Work From Anywhere Request', + defaultCollection: { + name: 'workFromAnywhereRequests', + label: 'Work From Anywhere Request', + }, + fields: [ + { id: 1, name: 'location', type: 'TEXT' }, + { id: 2, name: 'startDate', type: 'DATE', precision: 'DAY' }, + { id: 3, name: 'rightToWork', type: 'BOOLEAN' }, + { id: 4, name: 'createdBy', type: 'SINGLE_INSTANCE', target: 'WORKER' }, + { + id: 5, + name: 'rightToWorkVerification', + type: 'MULTI_INSTANCE', + target: 'RightToWorkVerification', + }, + ], +}; + +describe('parseBusinessObject()', () => { + it('returns a ModelSchema from a business object definition', () => { + const result = parseBusinessObject(SIMPLE_OBJECT); + + expect(result).toBeDefined(); + }); + + describe('name', () => { + it('uses the business object name', () => { + const result = parseBusinessObject(SIMPLE_OBJECT); + + expect(result.name).toBe('DemoObj'); + }); + }); + + describe('label', () => { + it('uses the business object label', () => { + const result = parseBusinessObject(SIMPLE_OBJECT); + + expect(result.label).toBe('Demo Obj'); + }); + }); + + describe('collection', () => { + it('uses the defaultCollection name', () => { + const result = parseBusinessObject(SIMPLE_OBJECT); + + expect(result.collection).toBe('demoObjs'); + }); + }); + + describe('fields', () => { + it('maps TEXT fields', () => { + const result = parseBusinessObject(SIMPLE_OBJECT); + + expect(result.fields[0]).toEqual({ name: 'myField1', type: 'TEXT' }); + }); + + it('maps DATE fields with precision', () => { + const result = parseBusinessObject(COMPLEX_OBJECT); + const dateField = result.fields.find((f) => f.name === 'startDate'); + + expect(dateField).toEqual({ name: 'startDate', type: 'DATE', precision: 'DAY' }); + }); + + it('maps BOOLEAN fields', () => { + const result = parseBusinessObject(COMPLEX_OBJECT); + const boolField = result.fields.find((f) => f.name === 'rightToWork'); + + expect(boolField).toEqual({ name: 'rightToWork', type: 'BOOLEAN' }); + }); + + it('maps SINGLE_INSTANCE fields with target', () => { + const result = parseBusinessObject(COMPLEX_OBJECT); + const refField = result.fields.find((f) => f.name === 'createdBy'); + + expect(refField).toEqual({ + name: 'createdBy', + type: 'SINGLE_INSTANCE', + target: 'WORKER', + }); + }); + + it('maps MULTI_INSTANCE fields with target', () => { + const result = parseBusinessObject(COMPLEX_OBJECT); + const multiField = result.fields.find((f) => f.name === 'rightToWorkVerification'); + + expect(multiField).toEqual({ + name: 'rightToWorkVerification', + type: 'MULTI_INSTANCE', + target: 'RightToWorkVerification', + }); + }); + }); +}); diff --git a/cli/tests/commands/everywhere/bind.test.ts b/cli/tests/commands/everywhere/bind.test.ts new file mode 100644 index 0000000..89da28a --- /dev/null +++ b/cli/tests/commands/everywhere/bind.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import BindCommand from '../../../src/commands/everywhere/bind'; +import EverywhereBaseCommand from '../../../src/commands/everywhere/base'; + +describe('everywhere bind', () => { + it('exists as a command class', () => { + expect(BindCommand).toBeDefined(); + }); + + describe('description', () => { + it('describes generating data bindings', () => { + expect(BindCommand.description).toBe( + 'Generate TypeScript types and data hooks from Workday Extend business object models.' + ); + }); + }); + + describe('flags', () => { + it('inherits the plugin-dir flag from the base command', () => { + expect(BindCommand.flags['plugin-dir']).toBe(EverywhereBaseCommand.baseFlags['plugin-dir']); + }); + }); + + describe('args', () => { + it('accepts an optional app-dir argument', () => { + expect(BindCommand.args['app-dir']).toBeDefined(); + }); + }); +}); diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 0000000..33c0cf9 --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,15 @@ +export type FieldType = 'TEXT' | 'DATE' | 'BOOLEAN' | 'SINGLE_INSTANCE' | 'MULTI_INSTANCE'; + +export interface FieldSchema { + name: string; + type: FieldType; + target?: string; + precision?: string; +} + +export interface ModelSchema { + name: string; + label: string; + collection: string; + fields: FieldSchema[]; +} diff --git a/src/index.ts b/src/index.ts index bb82a20..5a0d8a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,3 +10,5 @@ export { NavigationProvider } from './hooks/index.js'; export type { NavigationProviderProps, NavigationState } from './hooks/index.js'; export { useNavigate } from './hooks/index.js'; export { useParams } from './hooks/index.js'; + +export type { FieldType, FieldSchema, ModelSchema } from './data/types.js'; diff --git a/tests/data/types.test.ts b/tests/data/types.test.ts new file mode 100644 index 0000000..88ce2ce --- /dev/null +++ b/tests/data/types.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import type { FieldType, FieldSchema, ModelSchema } from '../../src/data/types.js'; + +describe('data schema types', () => { + describe('FieldType', () => { + it('accepts TEXT as a valid field type', () => { + const fieldType: FieldType = 'TEXT'; + + expect(fieldType).toBe('TEXT'); + }); + + it('accepts DATE as a valid field type', () => { + const fieldType: FieldType = 'DATE'; + + expect(fieldType).toBe('DATE'); + }); + + it('accepts BOOLEAN as a valid field type', () => { + const fieldType: FieldType = 'BOOLEAN'; + + expect(fieldType).toBe('BOOLEAN'); + }); + + it('accepts SINGLE_INSTANCE as a valid field type', () => { + const fieldType: FieldType = 'SINGLE_INSTANCE'; + + expect(fieldType).toBe('SINGLE_INSTANCE'); + }); + + it('accepts MULTI_INSTANCE as a valid field type', () => { + const fieldType: FieldType = 'MULTI_INSTANCE'; + + expect(fieldType).toBe('MULTI_INSTANCE'); + }); + }); + + describe('FieldSchema', () => { + it('requires name and type', () => { + const field: FieldSchema = { name: 'location', type: 'TEXT' }; + + expect(field.name).toBe('location'); + }); + + it('allows optional target for reference types', () => { + const field: FieldSchema = { + name: 'createdBy', + type: 'SINGLE_INSTANCE', + target: 'WORKER', + }; + + expect(field.target).toBe('WORKER'); + }); + + it('allows optional precision for date types', () => { + const field: FieldSchema = { + name: 'startDate', + type: 'DATE', + precision: 'DAY', + }; + + expect(field.precision).toBe('DAY'); + }); + }); + + describe('ModelSchema', () => { + it('requires name, label, collection, and fields', () => { + const schema: ModelSchema = { + name: 'Employee', + label: 'Employee', + collection: 'employees', + fields: [{ name: 'title', type: 'TEXT' }], + }; + + expect(schema.name).toBe('Employee'); + }); + + it('uses collection name from defaultCollection', () => { + const schema: ModelSchema = { + name: 'WorkFromAnywhereRequest', + label: 'Work From Anywhere Request', + collection: 'workFromAnywhereRequests', + fields: [], + }; + + expect(schema.collection).toBe('workFromAnywhereRequests'); + }); + }); +});