diff --git a/tests/core/tests/schema-dsl/resource-decorator-test.ts b/tests/core/tests/schema-dsl/resource-decorator-test.ts index dc598e2db3a..b779d100382 100644 --- a/tests/core/tests/schema-dsl/resource-decorator-test.ts +++ b/tests/core/tests/schema-dsl/resource-decorator-test.ts @@ -1,4 +1,4 @@ -import schemas from 'virtual:warp-drive-schemas'; +import schemas, { objects, traits } from 'virtual:warp-drive-schemas'; import { withDefaults } from '@warp-drive/core/reactive'; import { module, setupTest, test } from '@warp-drive/diagnostic/ember'; @@ -7,13 +7,6 @@ module('Schema DSL | @Resource compilation', function (hooks) { setupTest(hooks); test('@Resource derives type from class name and compiles fields', function (assert) { - // @Resource - // class User { - // @field declare firstName: string; - // @field declare lastName: string; - // @field declare email: string; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'user'); assert.deepEqual(schema, { @@ -24,17 +17,15 @@ module('Schema DSL | @Resource compilation', function (hooks) { { kind: 'field', name: 'firstName' }, { kind: 'field', name: 'lastName' }, { kind: 'field', name: 'email' }, + { kind: '@local', name: 'isEditing' }, + { kind: '@local', name: 'dirtyCount', options: { defaultValue: 0 } }, + { kind: 'derived', name: 'displayName', type: '@concat' }, { kind: 'derived', name: 'constructor', type: '@constructor' }, ], }); }); test('@Resource("person") overrides the type name', function (assert) { - // @Resource('person') - // class CustomUser { - // @field declare name: string; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'person'); assert.deepEqual(schema, { @@ -49,13 +40,6 @@ module('Schema DSL | @Resource compilation', function (hooks) { }); test('@id sets a custom identity field', function (assert) { - // @Resource - // class Post { - // @id declare uuid: string; - // @field declare title: string; - // @field({ type: 'date-time' }) declare createdAt: Date; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'post'); assert.deepEqual(schema, { @@ -64,19 +48,15 @@ module('Schema DSL | @Resource compilation', function (hooks) { fields: [ { kind: 'derived', name: '$type', type: '@identity', options: { key: 'type' } }, { kind: 'field', name: 'title' }, - { kind: 'field', name: 'createdAt', type: 'date-time' }, + { kind: 'field', name: 'createdAt' }, + { kind: 'object', name: 'metadata' }, + { kind: 'array', name: 'tags' }, { kind: 'derived', name: 'constructor', type: '@constructor' }, ], }); }); test('@field({ sourceKey }) maps the API key to the field name', function (assert) { - // @Resource - // class Product { - // @field({ sourceKey: 'product_name' }) declare name: string; - // @field({ type: 'number', sourceKey: 'unit_price' }) declare price: number; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'product'); assert.deepEqual(schema, { @@ -86,18 +66,13 @@ module('Schema DSL | @Resource compilation', function (hooks) { { kind: 'derived', name: '$type', type: '@identity', options: { key: 'type' } }, { kind: 'field', name: 'name', sourceKey: 'product_name' }, { kind: 'field', name: 'price', type: 'number', sourceKey: 'unit_price' }, + { kind: 'alias', name: 'productName', type: null, options: { kind: 'field', name: 'name' } }, { kind: 'derived', name: 'constructor', type: '@constructor' }, ], }); }); test('@field({ sourceKey }) calling withDefaults setups up the defaults', function (assert) { - // @Resource - // class Product { - // @field({ sourceKey: 'product_name' }) declare name: string; - // @field({ type: 'number', sourceKey: 'unit_price' }) declare price: number; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'product'); assert.deepEqual( @@ -108,32 +83,202 @@ module('Schema DSL | @Resource compilation', function (hooks) { fields: [ { kind: 'field', name: 'name', sourceKey: 'product_name' }, { kind: 'field', name: 'price', type: 'number', sourceKey: 'unit_price' }, + { kind: 'alias', name: 'productName', type: null, options: { kind: 'field', name: 'name' } }, ], }) ); }); test('@Resource({ legacy: true }) omits derived fields and sets legacy flag', function (assert) { - // @Resource({ legacy: true }) - // class Comment { - // @field declare body: string; - // } - const schema = schemas.find((s: { type: string }) => s.type === 'comment'); assert.deepEqual(schema, { type: 'comment', identity: { kind: '@id', name: 'id' }, - fields: [{ kind: 'field', name: 'body' }], + fields: [ + { kind: 'field', name: 'body' }, + { kind: 'attribute', name: 'author', type: 'string' }, + { kind: 'belongsTo', name: 'post', type: 'post', options: { async: true, inverse: 'comments' } }, + { kind: 'hasMany', name: 'replies', type: 'comment', options: { async: false, inverse: null } }, + ], legacy: true, + traits: ['timestamped'], }); }); +}); + +module('Schema DSL | @local', function (hooks) { + setupTest(hooks); + + test('@local compiles to @local field', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'user'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; - test('compiles all schema files in the glob', function (assert) { + const isEditing = fields.find((f) => f.name === 'isEditing'); + assert.deepEqual(isEditing, { kind: '@local', name: 'isEditing' }); + }); + + test('@local({ defaultValue }) includes options', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'user'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const dirtyCount = fields.find((f) => f.name === 'dirtyCount'); + assert.deepEqual(dirtyCount, { kind: '@local', name: 'dirtyCount', options: { defaultValue: 0 } }); + }); +}); + +module('Schema DSL | @derived', function (hooks) { + setupTest(hooks); + + test('@derived compiles to derived field with type', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'user'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const displayName = fields.find((f) => f.name === 'displayName'); + assert.deepEqual(displayName, { kind: 'derived', name: 'displayName', type: '@concat' }); + }); +}); + +module('Schema DSL | @object and @array', function (hooks) { + setupTest(hooks); + + test('@object compiles to object field', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'post'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const metadata = fields.find((f) => f.name === 'metadata'); + assert.deepEqual(metadata, { kind: 'object', name: 'metadata' }); + }); + + test('@array compiles to array field', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'post'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const tags = fields.find((f) => f.name === 'tags'); + assert.deepEqual(tags, { kind: 'array', name: 'tags' }); + }); +}); + +module('Schema DSL | @alias', function (hooks) { + setupTest(hooks); + + test('@alias compiles to alias field pointing to source', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'product'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const productName = fields.find((f) => f.name === 'productName'); + assert.deepEqual(productName, { + kind: 'alias', + name: 'productName', + type: null, + options: { kind: 'field', name: 'name' }, + }); + }); +}); + +module('Schema DSL | legacy decorators', function (hooks) { + setupTest(hooks); + + test('@attribute compiles to attribute field', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'comment'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const author = fields.find((f) => f.name === 'author'); + assert.deepEqual(author, { kind: 'attribute', name: 'author', type: 'string' }); + }); + + test('@belongsTo compiles to belongsTo field with options', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'comment'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const post = fields.find((f) => f.name === 'post'); + assert.deepEqual(post, { + kind: 'belongsTo', + name: 'post', + type: 'post', + options: { async: true, inverse: 'comments' }, + }); + }); + + test('@hasMany compiles to hasMany field with options', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'comment'); + const fields = (schema as { fields: Array<{ kind: string; name: string }> }).fields; + + const replies = fields.find((f) => f.name === 'replies'); + assert.deepEqual(replies, { + kind: 'hasMany', + name: 'replies', + type: 'comment', + options: { async: false, inverse: null }, + }); + }); +}); + +module('Schema DSL | @trait composition', function (hooks) { + setupTest(hooks); + + test('@trait adds traits array to resource schema', function (assert) { + const schema = schemas.find((s: { type: string }) => s.type === 'comment'); + assert.deepEqual((schema as { traits?: string[] }).traits, ['timestamped']); + }); +}); + +module('Schema DSL | @Object compilation', function (hooks) { + setupTest(hooks); + + test('@ObjectSchema compiles to ObjectSchema with hash identity', function (assert) { + assert.ok(Array.isArray(objects), 'objects export is an array'); + const schema = objects.find((s: { type: string }) => s.type === 'address'); + assert.ok(schema, 'address object schema found'); + + assert.deepEqual(schema, { + type: 'address', + identity: { kind: '@hash', name: 'addressHash', type: '@identity' }, + fields: [ + { kind: 'field', name: 'street' }, + { kind: 'field', name: 'city' }, + ], + }); + }); +}); + +module('Schema DSL | @Trait compilation', function (hooks) { + setupTest(hooks); + + test('@Trait compiles to Trait schema', function (assert) { + assert.ok(Array.isArray(traits), 'traits export is an array'); + const schema = traits.find((s: { name: string }) => s.name === 'timestamped'); + assert.ok(schema, 'timestamped trait found'); + + assert.deepEqual(schema, { + name: 'timestamped', + mode: 'polaris', + fields: [ + { kind: 'field', name: 'createdAt' }, + { kind: 'field', name: 'updatedAt' }, + ], + }); + }); +}); + +module('Schema DSL | compilation totals', function (hooks) { + setupTest(hooks); + + test('compiles all resource schemas', function (assert) { assert.ok(Array.isArray(schemas), 'compiled output is an array'); - assert.equal(schemas.length, 5, 'all five schema files were compiled'); + assert.equal(schemas.length, 5, 'five resource schemas compiled'); const types = schemas.map((s: { type: string }) => s.type).sort(); - assert.deepEqual(types, ['comment', 'person', 'post', 'product', 'user'], 'all expected types present'); + assert.deepEqual(types, ['comment', 'person', 'post', 'product', 'user']); + }); + + test('compiles object schemas', function (assert) { + assert.ok(Array.isArray(objects), 'objects export is an array'); + assert.equal(objects.length, 1, 'one object schema compiled'); + }); + + test('compiles trait schemas', function (assert) { + assert.ok(Array.isArray(traits), 'traits export is an array'); + assert.equal(traits.length, 1, 'one trait schema compiled'); }); }); diff --git a/tests/core/tests/schema-dsl/schemas/address.ts b/tests/core/tests/schema-dsl/schemas/address.ts new file mode 100644 index 00000000000..3d8f8474560 --- /dev/null +++ b/tests/core/tests/schema-dsl/schemas/address.ts @@ -0,0 +1,8 @@ +import { field, hash, ObjectSchema } from '@warp-drive/schema-dsl'; + +@ObjectSchema +export class Address { + @hash({ type: '@identity' }) declare addressHash: string; + @field declare street: string; + @field declare city: string; +} diff --git a/tests/core/tests/schema-dsl/schemas/comment.ts b/tests/core/tests/schema-dsl/schemas/comment.ts index 6dcc5ff35e1..42e16586052 100644 --- a/tests/core/tests/schema-dsl/schemas/comment.ts +++ b/tests/core/tests/schema-dsl/schemas/comment.ts @@ -1,6 +1,12 @@ -import { field, Resource } from '@warp-drive/schema-dsl'; +import { attribute, belongsTo, field, hasMany, Resource, trait } from '@warp-drive/schema-dsl'; + +import { Timestamped } from './timestamped.ts'; @Resource({ legacy: true }) +@trait(Timestamped) export class Comment { @field declare body: string; + @attribute({ type: 'string' }) declare author: string; + @belongsTo({ type: 'post', inverse: 'comments', async: true }) declare post: unknown; + @hasMany({ type: 'comment', inverse: null, async: false }) declare replies: unknown[]; } diff --git a/tests/core/tests/schema-dsl/schemas/post.ts b/tests/core/tests/schema-dsl/schemas/post.ts index 9b2729539a8..9fc418343b7 100644 --- a/tests/core/tests/schema-dsl/schemas/post.ts +++ b/tests/core/tests/schema-dsl/schemas/post.ts @@ -1,8 +1,10 @@ -import { field, id, Resource } from '@warp-drive/schema-dsl'; +import { array, field, id, object, Resource } from '@warp-drive/schema-dsl'; @Resource export class Post { @id declare uuid: string; @field declare title: string; - @field({ type: 'date-time' }) declare createdAt: Date; + @field declare createdAt: string; + @object declare metadata: Record; + @array declare tags: string[]; } diff --git a/tests/core/tests/schema-dsl/schemas/product.ts b/tests/core/tests/schema-dsl/schemas/product.ts index b3fbc956fab..9fc3f92fd42 100644 --- a/tests/core/tests/schema-dsl/schemas/product.ts +++ b/tests/core/tests/schema-dsl/schemas/product.ts @@ -1,7 +1,8 @@ -import { field, Resource } from '@warp-drive/schema-dsl'; +import { alias, field, Resource } from '@warp-drive/schema-dsl'; @Resource export class Product { @field({ sourceKey: 'product_name' }) declare name: string; @field({ type: 'number', sourceKey: 'unit_price' }) declare price: number; + @alias({ kind: 'field', name: 'name' }) declare productName: string; } diff --git a/tests/core/tests/schema-dsl/schemas/timestamped.ts b/tests/core/tests/schema-dsl/schemas/timestamped.ts new file mode 100644 index 00000000000..1beb896d27f --- /dev/null +++ b/tests/core/tests/schema-dsl/schemas/timestamped.ts @@ -0,0 +1,7 @@ +import { field, Trait } from '@warp-drive/schema-dsl'; + +@Trait +export class Timestamped { + @field declare createdAt: string; + @field declare updatedAt: string; +} diff --git a/tests/core/tests/schema-dsl/schemas/user.ts b/tests/core/tests/schema-dsl/schemas/user.ts index c2ac9a8054e..4bc6170f62f 100644 --- a/tests/core/tests/schema-dsl/schemas/user.ts +++ b/tests/core/tests/schema-dsl/schemas/user.ts @@ -1,8 +1,11 @@ -import { field, Resource } from '@warp-drive/schema-dsl'; +import { derived, field, local, Resource } from '@warp-drive/schema-dsl'; @Resource export class User { @field declare firstName: string; @field declare lastName: string; @field declare email: string; + @local declare isEditing: boolean; + @local({ defaultValue: 0 }) declare dirtyCount: number; + @derived({ type: '@concat' }) declare displayName: string; } diff --git a/warp-drive-packages/schema-dsl/src/index.ts b/warp-drive-packages/schema-dsl/src/index.ts index 0bf61553bbf..7187bbbaa15 100644 --- a/warp-drive-packages/schema-dsl/src/index.ts +++ b/warp-drive-packages/schema-dsl/src/index.ts @@ -7,22 +7,22 @@ * @module @warp-drive/schema-dsl */ +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +type AnyConstructor = abstract new (...args: unknown[]) => unknown; +type PrimitiveValue = string | number | boolean | null; + +// --------------------------------------------------------------------------- +// @Resource +// --------------------------------------------------------------------------- + export interface ResourceOptions { legacy?: boolean; identityField?: string; } -export interface FieldOptions { - type?: string; - sourceKey?: string; -} - -export interface IdOptions { - sourceKey?: string; -} - -type AnyConstructor = abstract new (...args: unknown[]) => unknown; - /** * Marks a class as a resource schema. * @@ -36,6 +36,70 @@ export function Resource( _maybeOptions?: unknown ): void | ((target: AnyConstructor) => void) {} +// --------------------------------------------------------------------------- +// @Object +// --------------------------------------------------------------------------- + +export interface ObjectSchemaOptions { + hash?: boolean; +} + +/** + * Marks a class as an object schema (non-resource, no unique identity). + * + * @public + */ +export function ObjectSchema(target: AnyConstructor): void; +export function ObjectSchema(type: string, options?: ObjectSchemaOptions): (target: AnyConstructor) => void; +export function ObjectSchema(options: ObjectSchemaOptions): (target: AnyConstructor) => void; +export function ObjectSchema( + _targetOrTypeOrOptions?: unknown, + _maybeOptions?: unknown +): void | ((target: AnyConstructor) => void) {} + +// --------------------------------------------------------------------------- +// @Trait +// --------------------------------------------------------------------------- + +export interface TraitOptions { + mode?: 'legacy' | 'polaris'; +} + +/** + * Marks a class as a trait schema (reusable field set). + * + * @public + */ +export function Trait(target: AnyConstructor): void; +export function Trait(name: string, options?: TraitOptions): (target: AnyConstructor) => void; +export function Trait(options: TraitOptions): (target: AnyConstructor) => void; +export function Trait( + _targetOrNameOrOptions?: unknown, + _maybeOptions?: unknown +): void | ((target: AnyConstructor) => void) {} + +// --------------------------------------------------------------------------- +// @trait (composition) +// --------------------------------------------------------------------------- + +/** + * Composes traits into a Resource, Object, or Trait class. + * + * @public + */ +export function trait(..._traits: AnyConstructor[]): (target: AnyConstructor) => void { + return function (_target: AnyConstructor): void {}; +} + +// --------------------------------------------------------------------------- +// @field +// --------------------------------------------------------------------------- + +export interface FieldOptions { + type?: string; + sourceKey?: string; +} + /** * Marks a property as a field on the resource schema. * @@ -48,6 +112,14 @@ export function field( _propertyKey?: string ): void | ((target: object, key: string) => void) {} +// --------------------------------------------------------------------------- +// @id +// --------------------------------------------------------------------------- + +export interface IdOptions { + sourceKey?: string; +} + /** * Marks a property as the identity field for the resource. * @@ -56,3 +128,243 @@ export function field( export function id(target: object, key: string): void; export function id(options: IdOptions): (target: object, key: string) => void; export function id(_targetOrOptions?: unknown, _propertyKey?: string): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @local +// --------------------------------------------------------------------------- + +export interface LocalOptions { + defaultValue?: PrimitiveValue; +} + +/** + * Marks a property as a local field (not stored in cache, not sent to server). + * + * @public + */ +export function local(target: object, key: string): void; +export function local(options: LocalOptions): (target: object, key: string) => void; +export function local( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @hash +// --------------------------------------------------------------------------- + +export interface HashOptions { + type: string; +} + +/** + * Marks a property as the hash identity field for an object schema. + * + * @public + */ +export function hash(options: HashOptions): (target: object, key: string) => void; +export function hash( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @object +// --------------------------------------------------------------------------- + +export interface ObjectFieldOptions { + sourceKey?: string; + type?: string; +} + +/** + * Marks a property as an unstructured object field. + * + * @public + */ +export function object(target: object, key: string): void; +export function object(options: ObjectFieldOptions): (target: object, key: string) => void; +export function object( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @array +// --------------------------------------------------------------------------- + +export interface ArrayFieldOptions { + sourceKey?: string; + type?: string; +} + +/** + * Marks a property as an array field of primitive values. + * + * @public + */ +export function array(target: object, key: string): void; +export function array(options: ArrayFieldOptions): (target: object, key: string) => void; +export function array( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @derived +// --------------------------------------------------------------------------- + +export interface DerivedOptions { + type: string; + options?: Record; +} + +/** + * Marks a property as a derived (computed, read-only) field. + * + * @public + */ +export function derived(options: DerivedOptions): (target: object, key: string) => void; +export function derived( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @alias +// --------------------------------------------------------------------------- + +export interface AliasOptions { + kind: string; + name: string; + type?: string; + sourceKey?: string; +} + +/** + * Marks a property as an alias to another field in the cache. + * + * @public + */ +export function alias(options: AliasOptions): (target: object, key: string) => void; +export function alias( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @attribute (legacy) +// --------------------------------------------------------------------------- + +export interface AttributeOptions { + sourceKey?: string; + type?: string; +} + +/** + * Marks a property as a legacy attribute field. + * + * @public + */ +export function attribute(target: object, key: string): void; +export function attribute(options: AttributeOptions): (target: object, key: string) => void; +export function attribute( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @belongsTo (legacy) +// --------------------------------------------------------------------------- + +export interface BelongsToOptions { + type: string; + inverse: string | null; + async?: boolean; + polymorphic?: boolean; + as?: string; + sourceKey?: string; +} + +/** + * Marks a property as a legacy belongsTo relationship. + * + * @public + */ +export function belongsTo(options: BelongsToOptions): (target: object, key: string) => void; +export function belongsTo( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// @hasMany (legacy) +// --------------------------------------------------------------------------- + +export interface HasManyOptions { + type: string; + inverse: string | null; + async?: boolean; + polymorphic?: boolean; + as?: string; + sourceKey?: string; +} + +/** + * Marks a property as a legacy hasMany relationship. + * + * @public + */ +export function hasMany(options: HasManyOptions): (target: object, key: string) => void; +export function hasMany( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +// --------------------------------------------------------------------------- +// Modifier decorators (type-generation only, no-op) +// --------------------------------------------------------------------------- + +/** + * Marks a field as readonly (cannot be created or edited). + * + * @public + */ +export function readonly(target: object, key: string): void; +export function readonly( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +/** + * Marks a field as optional during creation. + * + * @public + */ +export function optional(target: object, key: string): void; +export function optional( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +/** + * Marks a field as create-only (cannot be edited after creation). + * + * @public + */ +export function createonly(target: object, key: string): void; +export function createonly( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} + +/** + * Marks a field as edit-only (cannot be set during creation). + * + * @public + */ +export function editonly(target: object, key: string): void; +export function editonly( + _targetOrOptions?: unknown, + _propertyKey?: string +): void | ((target: object, key: string) => void) {} diff --git a/warp-drive-packages/schema-dsl/src/vite-plugin.js b/warp-drive-packages/schema-dsl/src/vite-plugin.js index 60a90a24d8b..68e73ede977 100644 --- a/warp-drive-packages/schema-dsl/src/vite-plugin.js +++ b/warp-drive-packages/schema-dsl/src/vite-plugin.js @@ -21,6 +21,15 @@ function extractStringValue(node) { return undefined; } +function extractPrimitiveValue(node) { + if (!node) return undefined; + if (node.type === 'StringLiteral') return node.value; + if (node.type === 'BooleanLiteral') return node.value; + if (node.type === 'NumericLiteral') return node.value; + if (node.type === 'NullLiteral') return null; + return undefined; +} + function extractObjectOptions(node) { const opts = {}; if (!node || node.type !== 'ObjectExpression') return opts; @@ -30,9 +39,10 @@ function extractObjectOptions(node) { const key = prop.key.type === 'Identifier' ? prop.key.name : extractStringValue(prop.key); if (!key) continue; - if (prop.value.type === 'StringLiteral') opts[key] = prop.value.value; - else if (prop.value.type === 'BooleanLiteral') opts[key] = prop.value.value; - else if (prop.value.type === 'NumericLiteral') opts[key] = prop.value.value; + const val = extractPrimitiveValue(prop.value); + if (val !== undefined) { + opts[key] = val; + } } return opts; @@ -46,6 +56,10 @@ function decoratorName(node) { return null; } +// --------------------------------------------------------------------------- +// Parse functions for class decorators +// --------------------------------------------------------------------------- + function parseResourceArgs(node) { if (node.type !== 'Decorator') return null; const expr = node.expression; @@ -69,6 +83,69 @@ function parseResourceArgs(node) { return null; } +function parseObjectClassArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + + if (expr.type === 'CallExpression') { + const args = expr.arguments; + if (args.length === 0) return {}; + + if (args[0].type === 'StringLiteral') { + return { type: args[0].value }; + } + if (args[0].type === 'ObjectExpression') { + return extractObjectOptions(args[0]); + } + } + + return null; +} + +function parseTraitClassArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + + if (expr.type === 'CallExpression') { + const args = expr.arguments; + if (args.length === 0) return {}; + + if (args[0].type === 'StringLiteral') { + const opts = args[1] ? extractObjectOptions(args[1]) : {}; + return { name: args[0].value, mode: opts.mode }; + } + if (args[0].type === 'ObjectExpression') { + const opts = extractObjectOptions(args[0]); + return { mode: opts.mode }; + } + } + + return null; +} + +function parseTraitCompositionArgs(node, importMap) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type !== 'CallExpression') return null; + + const traits = []; + for (const arg of expr.arguments) { + if (arg.type === 'Identifier') { + traits.push(dasherize(arg.name)); + } + } + return traits.length > 0 ? traits : null; +} + +// --------------------------------------------------------------------------- +// Parse functions for property decorators +// --------------------------------------------------------------------------- + function parseFieldArgs(node) { if (node.type !== 'Decorator') return null; const expr = node.expression; @@ -93,13 +170,136 @@ function parseIdArgs(node) { return null; } +function parseLocalArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { defaultValue: opts.defaultValue }; + } + return null; +} + +function parseHashArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { type: opts.type }; + } + return null; +} + +function parseObjectFieldArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { sourceKey: opts.sourceKey, type: opts.type }; + } + return null; +} + +function parseArrayFieldArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { sourceKey: opts.sourceKey, type: opts.type }; + } + return null; +} + +function parseDerivedArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { type: opts.type }; + } + return null; +} + +function parseAliasArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { kind: opts.kind, name: opts.name, type: opts.type, sourceKey: opts.sourceKey }; + } + return null; +} + +function parseAttributeArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'Identifier') return {}; + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { sourceKey: opts.sourceKey, type: opts.type }; + } + return null; +} + +function parseBelongsToArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { + type: opts.type, + inverse: opts.inverse !== undefined ? opts.inverse : null, + async: opts.async, + polymorphic: opts.polymorphic, + as: opts.as, + sourceKey: opts.sourceKey, + }; + } + return null; +} + +function parseHasManyArgs(node) { + if (node.type !== 'Decorator') return null; + const expr = node.expression; + + if (expr.type === 'CallExpression' && expr.arguments.length > 0) { + const opts = extractObjectOptions(expr.arguments[0]); + return { + type: opts.type, + inverse: opts.inverse !== undefined ? opts.inverse : null, + async: opts.async, + polymorphic: opts.polymorphic, + as: opts.as, + sourceKey: opts.sourceKey, + }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Schema extraction +// --------------------------------------------------------------------------- + function extractSchemas(source) { const ast = parse(source, { sourceType: 'module', plugins: ['typescript', ['decorators', { decoratorsBeforeExport: true }]], }); - const schemas = []; + const resources = []; + const objects = []; + const traits = []; const importMap = {}; traverse(ast, { @@ -117,24 +317,36 @@ function extractSchemas(source) { const node = path.node; if (!node.decorators || node.decorators.length === 0) return; - let resourceArgs = null; + // Determine which class decorator is applied + let classKind = null; // 'resource' | 'object' | 'trait' + let classArgs = null; + let composedTraits = null; + for (const dec of node.decorators) { const name = decoratorName(dec); if (!name) continue; const original = importMap[name] ?? name; - if (original === 'Resource') { - resourceArgs = parseResourceArgs(dec); - break; + + if (original === 'Resource' && !classArgs) { + classKind = 'resource'; + classArgs = parseResourceArgs(dec); + } else if (original === 'ObjectSchema' && !classArgs) { + classKind = 'object'; + classArgs = parseObjectClassArgs(dec); + } else if (original === 'Trait' && !classArgs) { + classKind = 'trait'; + classArgs = parseTraitClassArgs(dec); + } else if (original === 'trait') { + composedTraits = parseTraitCompositionArgs(dec, importMap); } } - if (!resourceArgs) return; - const className = node.id?.name; - const type = resourceArgs.type ?? (className ? dasherize(className) : 'unknown'); - const isLegacy = resourceArgs.legacy === true; + if (!classArgs) return; + const className = node.id?.name; const fields = []; let identity = null; + let hashField = null; for (const member of node.body.body) { if (member.type !== 'ClassProperty' || !member.decorators) continue; @@ -164,16 +376,137 @@ function extractSchemas(source) { } break; } + if (original === 'local') { + const opts = parseLocalArgs(dec); + if (opts) { + const f = { kind: '@local', name: propName }; + if (opts.defaultValue !== undefined) f.options = { defaultValue: opts.defaultValue }; + fields.push(f); + } + break; + } + if (original === 'hash') { + const opts = parseHashArgs(dec); + if (opts) { + hashField = { kind: '@hash', name: propName, type: opts.type }; + } + break; + } + if (original === 'object') { + const opts = parseObjectFieldArgs(dec); + if (opts) { + const f = { kind: 'object', name: propName }; + if (opts.type) f.type = opts.type; + if (opts.sourceKey) f.sourceKey = opts.sourceKey; + fields.push(f); + } + break; + } + if (original === 'array') { + const opts = parseArrayFieldArgs(dec); + if (opts) { + const f = { kind: 'array', name: propName }; + if (opts.type) f.type = opts.type; + if (opts.sourceKey) f.sourceKey = opts.sourceKey; + fields.push(f); + } + break; + } + if (original === 'derived') { + const opts = parseDerivedArgs(dec); + if (opts) { + const f = { kind: 'derived', name: propName, type: opts.type }; + fields.push(f); + } + break; + } + if (original === 'alias') { + const opts = parseAliasArgs(dec); + if (opts) { + const aliasTarget = { kind: opts.kind, name: opts.name }; + if (opts.type) aliasTarget.type = opts.type; + if (opts.sourceKey) aliasTarget.sourceKey = opts.sourceKey; + const f = { kind: 'alias', name: propName, type: null, options: aliasTarget }; + fields.push(f); + } + break; + } + if (original === 'attribute') { + const opts = parseAttributeArgs(dec); + if (opts) { + const f = { kind: 'attribute', name: propName }; + if (opts.type) f.type = opts.type; + if (opts.sourceKey) f.sourceKey = opts.sourceKey; + fields.push(f); + } + break; + } + if (original === 'belongsTo') { + const opts = parseBelongsToArgs(dec); + if (opts) { + const f = { kind: 'belongsTo', name: propName, type: opts.type, options: {} }; + f.options.async = opts.async !== undefined ? opts.async : false; + f.options.inverse = opts.inverse !== undefined ? opts.inverse : null; + if (opts.polymorphic) f.options.polymorphic = opts.polymorphic; + if (opts.as) f.options.as = opts.as; + if (opts.sourceKey) f.sourceKey = opts.sourceKey; + fields.push(f); + } + break; + } + if (original === 'hasMany') { + const opts = parseHasManyArgs(dec); + if (opts) { + const f = { kind: 'hasMany', name: propName, type: opts.type, options: {} }; + f.options.async = opts.async !== undefined ? opts.async : false; + f.options.inverse = opts.inverse !== undefined ? opts.inverse : null; + if (opts.polymorphic) f.options.polymorphic = opts.polymorphic; + if (opts.as) f.options.as = opts.as; + if (opts.sourceKey) f.sourceKey = opts.sourceKey; + fields.push(f); + } + break; + } } } - schemas.push({ type, isLegacy, identityField: resourceArgs.identityField, identity, fields }); + if (classKind === 'resource') { + const type = classArgs.type ?? (className ? dasherize(className) : 'unknown'); + resources.push({ + type, + isLegacy: classArgs.legacy === true, + identityField: classArgs.identityField, + identity, + fields, + traits: composedTraits, + }); + } else if (classKind === 'object') { + const type = classArgs.type ?? (className ? dasherize(className) : 'unknown'); + objects.push({ + type, + hashField, + fields, + }); + } else if (classKind === 'trait') { + const name = classArgs.name ?? (className ? dasherize(className) : 'unknown'); + const mode = classArgs.mode ?? 'polaris'; + traits.push({ + name, + mode, + fields, + traits: composedTraits, + }); + } }, }); - return schemas; + return { resources, objects, traits }; } +// --------------------------------------------------------------------------- +// Schema compilation +// --------------------------------------------------------------------------- + function toResourceSchema(info) { const identity = info.identity ? info.identity @@ -197,10 +530,34 @@ function toResourceSchema(info) { const schema = { type: info.type, identity, fields }; if (info.isLegacy) schema.legacy = true; + if (info.traits) schema.traits = info.traits; return schema; } +function toObjectSchema(info) { + const schema = { + type: info.type, + identity: info.hashField ?? null, + fields: info.fields, + }; + return schema; +} + +function toTrait(info) { + const schema = { + name: info.name, + mode: info.mode, + fields: info.fields, + }; + if (info.traits) schema.traits = info.traits; + return schema; +} + +// --------------------------------------------------------------------------- +// Vite plugin +// --------------------------------------------------------------------------- + /** * Vite plugin that compiles `@warp-drive/schema-dsl` decorated * classes into JSON resource schemas at build time. @@ -227,16 +584,31 @@ export function schemaDSL(options) { const pattern = resolve(root, options.schemas); const files = globSync(pattern); - const result = []; + const allResources = []; + const allObjects = []; + const allTraits = []; for (const file of files) { const source = readFileSync(file, 'utf-8'); - for (const info of extractSchemas(source)) { - result.push(toResourceSchema(info)); + const { resources, objects, traits } = extractSchemas(source); + + for (const info of resources) { + allResources.push(toResourceSchema(info)); + } + for (const info of objects) { + allObjects.push(toObjectSchema(info)); + } + for (const info of traits) { + allTraits.push(toTrait(info)); } } - return `export default ${JSON.stringify(result, null, 2)};`; + const lines = []; + lines.push(`export const resources = ${JSON.stringify(allResources, null, 2)};`); + lines.push(`export const objects = ${JSON.stringify(allObjects, null, 2)};`); + lines.push(`export const traits = ${JSON.stringify(allTraits, null, 2)};`); + lines.push(`export default resources;`); + return lines.join('\n'); }, }; } diff --git a/warp-drive-packages/schema-dsl/virtual.d.ts b/warp-drive-packages/schema-dsl/virtual.d.ts index 4f17b4765ef..76203050a93 100644 --- a/warp-drive-packages/schema-dsl/virtual.d.ts +++ b/warp-drive-packages/schema-dsl/virtual.d.ts @@ -1,5 +1,13 @@ declare module 'virtual:warp-drive-schemas' { - import { LegacyResourceSchema, PolarisResourceSchema } from '@warp-drive/core/types/schema/fields'; + import type { + LegacyResourceSchema, + ObjectSchema, + PolarisResourceSchema, + Trait, + } from '@warp-drive/core/types/schema/fields'; const schemas: Array; export default schemas; + export const resources: Array; + export const objects: Array; + export const traits: Array; }