diff --git a/src/compiler/common/mod.ts b/src/compiler/common/mod.ts new file mode 100644 index 00000000..de7b7982 --- /dev/null +++ b/src/compiler/common/mod.ts @@ -0,0 +1,2 @@ +export * from './ucc-list-options.js'; +export * from './unsupported-uc-schema.error.js'; diff --git a/src/compiler/common/ucc-list-options.ts b/src/compiler/common/ucc-list-options.ts new file mode 100644 index 00000000..10608e74 --- /dev/null +++ b/src/compiler/common/ucc-list-options.ts @@ -0,0 +1,3 @@ +export interface UccListOptions { + readonly single: 'accept' | 'as-is' | 'prefer' | 'reject'; +} diff --git a/src/compiler/unsupported-uc-schema.error.spec.ts b/src/compiler/common/unsupported-uc-schema.error.spec.ts similarity index 100% rename from src/compiler/unsupported-uc-schema.error.spec.ts rename to src/compiler/common/unsupported-uc-schema.error.spec.ts diff --git a/src/compiler/unsupported-uc-schema.error.ts b/src/compiler/common/unsupported-uc-schema.error.ts similarity index 77% rename from src/compiler/unsupported-uc-schema.error.ts rename to src/compiler/common/unsupported-uc-schema.error.ts index 5cc78e90..4031565e 100644 --- a/src/compiler/unsupported-uc-schema.error.ts +++ b/src/compiler/common/unsupported-uc-schema.error.ts @@ -1,5 +1,5 @@ -import { ucModelName } from '../schema/uc-model-name.js'; -import { UcSchema } from '../schema/uc-schema.js'; +import { ucModelName } from '../../schema/uc-model-name.js'; +import { UcSchema } from '../../schema/uc-schema.js'; export class UnsupportedUcSchemaError extends TypeError { diff --git a/src/compiler/deserialization/list.ucrx.class.ts b/src/compiler/deserialization/list.ucrx.class.ts index 6b64f764..36a34e2b 100644 --- a/src/compiler/deserialization/list.ucrx.class.ts +++ b/src/compiler/deserialization/list.ucrx.class.ts @@ -14,6 +14,8 @@ import { import { UcList } from '../../schema/list/uc-list.js'; import { ucModelName } from '../../schema/uc-model-name.js'; import { UcModel } from '../../schema/uc-schema.js'; +import { UccListOptions } from '../common/ucc-list-options.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { UC_MODULE_CHURI } from '../impl/uc-modules.js'; import { ucSchemaTypeSymbol } from '../impl/uc-schema-symbol.js'; import { ucSchemaVariant } from '../impl/uc-schema-variant.js'; @@ -22,7 +24,6 @@ import { UcrxCore } from '../rx/ucrx-core.js'; import { UcrxLib } from '../rx/ucrx-lib.js'; import { UcrxBeforeMod, UcrxMethod } from '../rx/ucrx-method.js'; import { UcrxClass, UcrxSignature } from '../rx/ucrx.class.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { UcdCompiler } from './ucd-compiler.js'; export class ListUcrxClass< @@ -30,17 +31,23 @@ export class ListUcrxClass< TItemModel extends UcModel = UcModel, > extends UcrxClass> { - static uccProcessSchema(compiler: UcdCompiler.Any, { item }: UcList.Schema): UccConfig { + static uccProcessSchema( + compiler: UcdCompiler.Any, + schema: UcList.Schema, + ): UccConfig { return { - configure: () => { - compiler.useUcrxClass('list', (lib, schema: UcList.Schema) => new this(lib, schema)); - compiler.processModel(item); + configure: options => { + compiler + .processModel(schema.item) + .useUcrxClass('list', (lib, schema: UcList.Schema) => new this(lib, schema)) + .useUcrxClass(schema, (lib, schema: UcList.Schema) => new this(lib, schema, options)); }, }; } readonly #itemClass: UcrxClass; readonly #isMatrix: boolean; + readonly #single: UccListOptions['single']; readonly #items: EsFieldHandle; readonly #addItem: EsMethodHandle<{ item: EsArg }>; @@ -50,7 +57,11 @@ export class ListUcrxClass< readonly #setListField: EsFieldHandle | undefined; readonly #itemRx: EsPropertyHandle | undefined; - constructor(lib: UcrxLib, schema: UcList.Schema) { + constructor( + lib: UcrxLib, + schema: UcList.Schema, + { single }: UccListOptions = { single: 'reject' }, + ) { let itemClass: UcrxClass; const { item } = schema; @@ -80,6 +91,7 @@ export class ListUcrxClass< this.#itemClass = itemClass; this.#isMatrix = isMatrix; + this.#single = single; this.#items = new EsField('items', { visibility: EsMemberVisibility.Private }).declareIn(this, { initializer: () => '[]', @@ -193,6 +205,18 @@ export class ListUcrxClass< return this.#items.get('this'); } + protected countItems(_cx: EsSnippet): EsSnippet { + return esline`${this.#items.get('this')}.length`; + } + + protected createSingle(_cx: EsSnippet): EsSnippet { + return esline`${this.#items.get('this')}[0]`; + } + + protected createEmptyList(_cx: EsSnippet): EsSnippet { + return '[]'; + } + protected createNullList(cx: EsSnippet): EsSnippet; protected createNullList(_cx: EsSnippet): EsSnippet { return 'null'; @@ -261,24 +285,79 @@ export class ListUcrxClass< args: { cx }, }, }) => code => { - if (isNull) { - code - .write(esline`if (${isNull.get('this')}) {`) - .indent(esline`${this.#setList}(${this.createNullList(cx)});`) - .write(esline`} else if (${listCreated.get('this')}) {`); - } else { - code.write(esline`if (${listCreated.get('this')}) {`); + switch (this.#single) { + case 'reject': + if (isNull) { + code + .write(esline`if (${isNull.get('this')}) {`) + .indent(this.#storeNullList(cx)) + .write(esline`} else if (${listCreated.get('this')}) {`); + } else { + code.write(esline`if (${listCreated.get('this')}) {`); + } + + code + .indent(this.#storeList(cx)) + .write('} else {') + .indent(esline`${cx}.reject(${ucrxRejectSingleItem}(this));`) + .write('}'); + + break; + case 'accept': + if (isNull) { + code + .write(esline`if (${isNull.get('this')}) {`) + .indent(this.#storeNullList(cx)) + .write(esline`} else {`) + .indent(this.#storeList(cx)) + .write('}'); + } else { + code.write(this.#storeList(cx)); + } + + break; + case 'as-is': + case 'prefer': + if (isNull) { + code + .write(esline`if (${isNull.get('this')}) {`) + .indent(this.#storeNullList(cx)) + .write(esline`} else if (${listCreated.get('this')}) {`); + } else { + code.write(esline`if (${listCreated.get('this')}) {`); + } + code + .indent( + this.#single === 'prefer' ? this.#storeSingleOrList(cx) : this.#storeList(cx), + ) + .write('} else {') + .indent(this.#storeSingleOrEmpty(cx)) + .write('}'); } - - code - .indent(esline`${this.#setList}(${this.createList(cx)});`) - .write(`} else {`) - .indent(esline`${cx}.reject(${ucrxRejectSingleItem}(this));`) - .write(`}`); }, }); } + #storeNullList(cx: EsSnippet): EsSnippet { + return esline`${this.#setList}(${this.createNullList(cx)});`; + } + + #storeList(cx: EsSnippet): EsSnippet { + return esline`${this.#setList}(${this.createList(cx)});`; + } + + #storeSingleOrList(cx: EsSnippet): EsSnippet { + return esline`${this.#setList}(${this.countItems(cx)} === 1 ? ${this.createSingle( + cx, + )} : ${this.createList(cx)});`; + } + + #storeSingleOrEmpty(cx: EsSnippet): EsSnippet { + return esline`${this.#setList}(${this.countItems(cx)} ? ${this.createSingle( + cx, + )} : ${this.createEmptyList(cx)});`; + } + #declareMatrixMethods(): void { const { itemClass } = this; const listCreated = this.#listCreated; diff --git a/src/compiler/deserialization/map.ucrx-entry.ts b/src/compiler/deserialization/map.ucrx-entry.ts index 3990a920..b6990e01 100644 --- a/src/compiler/deserialization/map.ucrx-entry.ts +++ b/src/compiler/deserialization/map.ucrx-entry.ts @@ -11,10 +11,10 @@ import { import { UcMap } from '../../schema/map/uc-map.js'; import { ucModelName } from '../../schema/uc-model-name.js'; import { UcModel, UcSchema } from '../../schema/uc-schema.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { ucSchemaTypeSymbol } from '../impl/uc-schema-symbol.js'; import { UcrxLib } from '../rx/ucrx-lib.js'; import { UcrxClass } from '../rx/ucrx.class.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { MapUcrxClass, MapUcrxStore } from './map.ucrx.class.js'; export class MapUcrxEntry { diff --git a/src/compiler/deserialization/ucd-function.ts b/src/compiler/deserialization/ucd-function.ts index 4fcd37b2..cabfb831 100644 --- a/src/compiler/deserialization/ucd-function.ts +++ b/src/compiler/deserialization/ucd-function.ts @@ -2,10 +2,10 @@ import { EsCode, EsFunction, EsSnippet, EsSymbol, EsVarKind, EsVarSymbol, esline import { UcDeserializer } from '../../schema/uc-deserializer.js'; import { ucModelName } from '../../schema/uc-model-name.js'; import { UcSchema } from '../../schema/uc-schema.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { UC_MODULE_DESERIALIZER } from '../impl/uc-modules.js'; import { ucSchemaTypeSymbol } from '../impl/uc-schema-symbol.js'; import { UcrxClass } from '../rx/ucrx.class.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { UcdExportSignature } from './ucd-export.signature.js'; import { UcdLib } from './ucd-lib.js'; diff --git a/src/compiler/mod.ts b/src/compiler/mod.ts index 87653395..d4cdf4e8 100644 --- a/src/compiler/mod.ts +++ b/src/compiler/mod.ts @@ -1,13 +1,13 @@ /** - * Schema compiler API. + * Schema compiler SPI. * - * __Schema compiler API considered unstable__ and may change for minor releases. + * __Schema compiler SPI considered unstable__ and may change for minor releases. * * @module churi/compiler.js */ +export * from './common/mod.js'; export * from './deserialization/mod.js'; export * from './processor/mod.js'; export * from './rx/mod.js'; export * from './serialization/mod.js'; -export * from './unsupported-uc-schema.error.js'; export * from './validation/mod.js'; diff --git a/src/compiler/serialization/ucs-function.ts b/src/compiler/serialization/ucs-function.ts index f4473704..2cc58c0c 100644 --- a/src/compiler/serialization/ucs-function.ts +++ b/src/compiler/serialization/ucs-function.ts @@ -1,9 +1,9 @@ import { EsFunction, EsSnippet, EsVarSymbol, esline } from 'esgen'; import { ucModelName } from '../../schema/uc-model-name.js'; import { UcSchema } from '../../schema/uc-schema.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { ucSchemaSymbol } from '../impl/uc-schema-symbol.js'; import { ucSchemaVariant } from '../impl/uc-schema-variant.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { UcsExportSignature } from './ucs-export.signature.js'; import { UcsLib } from './ucs-lib.js'; import { UcsWriterClass, UcsWriterSignature } from './ucs-writer.class.js'; diff --git a/src/compiler/serialization/ucs-support-list.ts b/src/compiler/serialization/ucs-support-list.ts index e884885b..4f3acff6 100644 --- a/src/compiler/serialization/ucs-support-list.ts +++ b/src/compiler/serialization/ucs-support-list.ts @@ -3,24 +3,68 @@ import { UcList } from '../../schema/list/uc-list.js'; import { ucModelName } from '../../schema/uc-model-name.js'; import { ucNullable } from '../../schema/uc-nullable.js'; import { ucOptional } from '../../schema/uc-optional.js'; -import { UcModel } from '../../schema/uc-schema.js'; +import { UcModel, UcSchema } from '../../schema/uc-schema.js'; +import { UccListOptions } from '../common/ucc-list-options.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { UC_MODULE_SERIALIZER } from '../impl/uc-modules.js'; import { UccConfig } from '../processor/ucc-config.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { UcsCompiler } from './ucs-compiler.js'; import { UcsFunction } from './ucs-function.js'; import { UcsSignature } from './ucs.signature.js'; -export function ucsSupportList(compiler: UcsCompiler, schema: UcList.Schema): UccConfig; -export function ucsSupportList(compiler: UcsCompiler, { item }: UcList.Schema): UccConfig { +export function ucsSupportList( + compiler: UcsCompiler, + schema: UcList.Schema, +): UccConfig { return { - configure() { - compiler.useUcsGenerator('list', ucsWriteList).processModel(item); + configure(options) { + compiler + .processModel(schema.item) + .useUcsGenerator(schema, (fn, schema, args) => ucsWriteList(fn, schema, args, options)); }, }; } function ucsWriteList>( + fn: UcsFunction, + schema: UcList.Schema, + args: UcsSignature.AllValues, + { single }: UccListOptions, +): EsSnippet { + const { writer, value } = args; + const itemSchema = schema.item.optional + ? ucOptional(ucNullable(schema.item), false) // Write `undefined` items as `null` + : schema.item; + + switch (single) { + case 'prefer': + return code => { + code + .write(esline`if (!Array.isArray(${value})) {`) + .indent(ucsWriteItem(fn, itemSchema, { writer, value })) + .write(esline`} else if (${value}.length === 1) {`) + .indent(ucsWriteItem(fn, itemSchema, { writer, value: esline`${value}[0]` })) + .write('} else {') + .indent(ucsWriteListItems(fn, schema, args)) + .write('}'); + }; + case 'as-is': + return code => { + code + .write(esline`if (Array.isArray(${value})) {`) + .indent(ucsWriteListItems(fn, schema, args)) + .write(esline`} else {`) + .indent(ucsWriteItem(fn, itemSchema, { writer, value })) + .write('}'); + }; + case 'accept': + case 'reject': + // Always an array. + return ucsWriteListItems(fn, schema, args); + } +} + +function ucsWriteListItems>( fn: UcsFunction, schema: UcList.Schema, { writer, value, asItem }: UcsSignature.AllValues, @@ -44,20 +88,7 @@ function ucsWriteList>( esline`await ${writer}.ready;`, esline`${writer}.write(${itemWritten} || !${asItem} ? ${comma} : ${openingParenthesis});`, esline`${itemWritten} = true;`, - fn.serialize( - itemSchema, - { - writer, - value: itemValue, - asItem: '1', - }, - (schema, fn) => { - throw new UnsupportedUcSchemaError( - schema, - `${fn}: Can not serialize list item of type "${ucModelName(schema)}"`, - ); - }, - ), + ucsWriteItem(fn, itemSchema, { writer, value: itemValue }), ) .write(`}`) .write(esline`if (${asItem}) {`) @@ -70,3 +101,16 @@ function ucsWriteList>( .write(`}`); }; } + +function ucsWriteItem( + fn: UcsFunction, + itemSchema: UcSchema, + args: Omit, +): EsSnippet { + return fn.serialize(itemSchema, { ...args, asItem: '1' }, (schema, fn) => { + throw new UnsupportedUcSchemaError( + schema, + `${fn}: Can not serialize list item of type "${ucModelName(schema)}"`, + ); + }); +} diff --git a/src/compiler/serialization/ucs-support-map.ts b/src/compiler/serialization/ucs-support-map.ts index b9e54c29..1b57bf04 100644 --- a/src/compiler/serialization/ucs-support-map.ts +++ b/src/compiler/serialization/ucs-support-map.ts @@ -13,10 +13,10 @@ import { ucModelName } from '../../schema/uc-model-name.js'; import { ucNullable } from '../../schema/uc-nullable.js'; import { ucOptional } from '../../schema/uc-optional.js'; import { UcSchema } from '../../schema/uc-schema.js'; +import { UnsupportedUcSchemaError } from '../common/unsupported-uc-schema.error.js'; import { UC_MODULE_SERIALIZER } from '../impl/uc-modules.js'; import { ucsCheckConstraints } from '../impl/ucs-check-constraints.js'; import { UccConfig } from '../processor/ucc-config.js'; -import { UnsupportedUcSchemaError } from '../unsupported-uc-schema.error.js'; import { UcsCompiler } from './ucs-compiler.js'; import { UcsFunction } from './ucs-function.js'; import { UcsLib } from './ucs-lib.js'; diff --git a/src/schema/list/mod.ts b/src/schema/list/mod.ts index b971e01f..2d45c54c 100644 --- a/src/schema/list/mod.ts +++ b/src/schema/list/mod.ts @@ -1 +1,2 @@ export * from './uc-list.js'; +export * from './uc-multi-value.js'; diff --git a/src/schema/list/uc-list.deserializer.spec.ts b/src/schema/list/uc-list.deserializer.spec.ts index 71e58a17..579ebe20 100644 --- a/src/schema/list/uc-list.deserializer.spec.ts +++ b/src/schema/list/uc-list.deserializer.spec.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; import { asis } from '@proc7ts/primitives'; +import { UnsupportedUcSchemaError } from '../../compiler/common/unsupported-uc-schema.error.js'; import { UcdCompiler } from '../../compiler/deserialization/ucd-compiler.js'; -import { UnsupportedUcSchemaError } from '../../compiler/unsupported-uc-schema.error.js'; import { parseTokens, readTokens } from '../../spec/read-chunks.js'; import { ucMap } from '../map/uc-map.js'; import { UcDeserializer } from '../uc-deserializer.js'; @@ -20,76 +20,123 @@ describe('UcList deserializer', () => { errors = []; }); - let readList: UcDeserializer; + describe('with single: reject', () => { + let readList: UcDeserializer; - beforeAll(async () => { - const compiler = new UcdCompiler({ - models: { - readList: ucList(Number), - }, + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucList(Number), + }, + }); + + ({ readList } = await compiler.evaluate()); }); - ({ readList } = await compiler.evaluate()); - }); + it('deserializes list', async () => { + await expect(readList(readTokens('1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); + }); + it('deserializes list synchronously', () => { + expect(readList(parseTokens('1 , 2, 3 '))).toEqual([1, 2, 3]); + }); + it('deserializes empty list', async () => { + await expect(readList(readTokens(', '))).resolves.toEqual([]); + }); + it('deserializes list with leading comma', async () => { + await expect(readList(readTokens(' , 1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); + }); + it('deserializes list with trailing comma', async () => { + await expect(readList(readTokens('1, 2, 3,'))).resolves.toEqual([1, 2, 3]); + }); + it('deserializes single item with leading comma', async () => { + await expect(readList(readTokens(' ,13 '))).resolves.toEqual([13]); + }); + it('deserializes single item with trailing comma', async () => { + await expect(readList(readTokens('13 , '))).resolves.toEqual([13]); + }); + it('rejects item instead of list', async () => { + await expect(readList(readTokens('13'), { onError })).resolves.toBeUndefined(); - it('deserializes list', async () => { - await expect(readList(readTokens('1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); - }); - it('deserializes list synchronously', () => { - expect(readList(parseTokens('1 , 2, 3 '))).toEqual([1, 2, 3]); - }); - it('deserializes empty list', async () => { - await expect(readList(readTokens(', '))).resolves.toEqual([]); - }); - it('deserializes list with leading comma', async () => { - await expect(readList(readTokens(' , 1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); - }); - it('deserializes list with trailing comma', async () => { - await expect(readList(readTokens('1, 2, 3,'))).resolves.toEqual([1, 2, 3]); - }); - it('deserializes single item with leading comma', async () => { - await expect(readList(readTokens(' ,13 '))).resolves.toEqual([13]); - }); - it('deserializes single item with trailing comma', async () => { - await expect(readList(readTokens('13 , '))).resolves.toEqual([13]); + expect(errors).toEqual([ + { + code: 'unexpectedType', + path: [{}], + details: { + types: ['number'], + expected: { + types: ['list'], + }, + }, + message: 'Unexpected single number instead of list', + }, + ]); + }); + it('does not deserialize unrecognized schema', async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucList({ type: 'test-type' }), + }, + }); + + let error: UnsupportedUcSchemaError | undefined; + + try { + await compiler.evaluate(); + } catch (e) { + error = e as UnsupportedUcSchemaError; + } + + expect(error).toBeInstanceOf(UnsupportedUcSchemaError); + expect(error?.schema.type).toBe('test-type'); + expect(error?.message).toBe('List: Can not deserialize list item of type "test-type"'); + expect(error?.cause).toBeInstanceOf(UnsupportedUcSchemaError); + expect((error?.cause as UnsupportedUcSchemaError).schema.type).toBe('test-type'); + }); }); - it('rejects item instead of list', async () => { - await expect(readList(readTokens('13'), { onError })).resolves.toBeUndefined(); - expect(errors).toEqual([ - { - code: 'unexpectedType', - path: [{}], - details: { - types: ['number'], - expected: { - types: ['list'], - }, + describe('with single: accept', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucList(Number, { single: 'accept' }), }, - message: 'Unexpected single number instead of list', - }, - ]); + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('accepts item instead of list', async () => { + await expect(readList(readTokens('13'), { onError })).resolves.toEqual([13]); + + expect(errors).toEqual([]); + }); }); - it('does not deserialize unrecognized schema', async () => { - const compiler = new UcdCompiler({ - models: { - readList: ucList({ type: 'test-type' }), - }, - }); - - let error: UnsupportedUcSchemaError | undefined; - - try { - await compiler.evaluate(); - } catch (e) { - error = e as UnsupportedUcSchemaError; - } - - expect(error).toBeInstanceOf(UnsupportedUcSchemaError); - expect(error?.schema.type).toBe('test-type'); - expect(error?.message).toBe('List: Can not deserialize list item of type "test-type"'); - expect(error?.cause).toBeInstanceOf(UnsupportedUcSchemaError); - expect((error?.cause as UnsupportedUcSchemaError).schema.type).toBe('test-type'); + + describe('nullable with single: accept', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucNullable(ucList(Number, { single: 'accept' })), + }, + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('accepts item instead of list', async () => { + await expect(readList(readTokens('13'), { onError })).resolves.toEqual([13]); + + expect(errors).toEqual([]); + }); + it('accepts null', async () => { + await expect(readList(readTokens('--'), { onError })).resolves.toBeNull(); + + expect(errors).toEqual([]); + }); }); describe('of booleans', () => { diff --git a/src/schema/list/uc-list.impl.ts b/src/schema/list/uc-list.impl.ts new file mode 100644 index 00000000..a8bf967d --- /dev/null +++ b/src/schema/list/uc-list.impl.ts @@ -0,0 +1,47 @@ +import { UccListOptions } from '../../compiler/common/ucc-list-options.js'; +import { COMPILER_MODULE } from '../../impl/module-names.js'; +import { ucModelName } from '../uc-model-name.js'; +import { UcModel, UcSchema, ucSchema } from '../uc-schema.js'; +import { UcList } from './uc-list.js'; +import { UcMultiValue } from './uc-multi-value.js'; + +export function createUcListSchema = UcSchema>( + itemModel: UcModel, + options: UcList.Options & UccListOptions, +): UcList.Schema; + +export function createUcListSchema = UcSchema>( + itemModel: UcModel, + options: UcMultiValue.Options & UccListOptions, +): UcMultiValue.Schema; + +export function createUcListSchema = UcSchema>( + itemModel: UcModel, + options: (UcList.Options | UcMultiValue.Options) & + UccListOptions, +): UcMultiValue.Schema { + const item = ucSchema(itemModel) as UcSchema.Of; + + return ucSchema>( + { + type: 'list', + where: { + deserializer: { + use: 'ListUcrxClass', + from: COMPILER_MODULE, + with: options, + }, + serializer: { + use: 'ucsSupportList', + from: COMPILER_MODULE, + with: options, + }, + }, + item, + toString() { + return `${ucModelName(item)}[]`; + }, + }, + options, + ); +} diff --git a/src/schema/list/uc-list.serializer.spec.ts b/src/schema/list/uc-list.serializer.spec.ts index 74273677..687a1ec3 100644 --- a/src/schema/list/uc-list.serializer.spec.ts +++ b/src/schema/list/uc-list.serializer.spec.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from '@jest/globals'; +import { UnsupportedUcSchemaError } from '../../compiler/common/unsupported-uc-schema.error.js'; import { UcsCompiler } from '../../compiler/serialization/ucs-compiler.js'; -import { UnsupportedUcSchemaError } from '../../compiler/unsupported-uc-schema.error.js'; import { TextOutStream } from '../../spec/text-out-stream.js'; import { ucMap } from '../map/uc-map.js'; import { UcString, ucString } from '../string/uc-string.js'; @@ -27,7 +27,7 @@ describe('UcList serializer', () => { it('serializes empty list', async () => { const compiler = new UcsCompiler({ models: { - writeList: ucList(Number), + writeList: ucList(Number, { single: 'accept' }), }, }); diff --git a/src/schema/list/uc-list.ts b/src/schema/list/uc-list.ts index d16f032a..3947bbe6 100644 --- a/src/schema/list/uc-list.ts +++ b/src/schema/list/uc-list.ts @@ -1,7 +1,5 @@ -import { COMPILER_MODULE } from '../../impl/module-names.js'; -import { UcConstraints } from '../uc-constraints.js'; -import { ucModelName } from '../uc-model-name.js'; -import { UcModel, UcSchema, ucSchema } from '../uc-schema.js'; +import { UcModel, UcSchema } from '../uc-schema.js'; +import { createUcListSchema } from './uc-list.impl.js'; /** * Data list represented as JavaScript array. @@ -37,10 +35,20 @@ export namespace UcList { * @typeParam TItem - Type of list item. * @typeParam TItemModel - Type of list item model. */ - export type Options< - TItem = unknown, - TItemModel extends UcModel = UcModel, - > = UcSchema.Extension>; + export interface Options = UcModel> + extends UcSchema.Extension> { + /** + * How to treat single values. + * + * One of: + * + * `'accept'` to treat single value as list with single item. + * `'reject'` (the default) to reject single value. + * + * This option is ignored if item type is a list itself. + */ + readonly single?: 'accept' | 'reject' | undefined; + } } /** @@ -49,6 +57,7 @@ export namespace UcList { * @typeParam TItem - Type of list item. * @typeParam TItemModel - Type of list item model. * @param itemModel - List item model. + * @param options * * @returns New list schema instance. */ @@ -62,28 +71,7 @@ export function ucList = UcSchema, options: UcList.Options = {}, ): UcList.Schema { - const item = ucSchema(itemModel) as UcSchema.Of; + const { single = 'reject' } = options; - return ucSchema>( - { - type: 'list', - where: UcList$constraints, - item, - toString() { - return `${ucModelName(item)}[]`; - }, - }, - options, - ); + return createUcListSchema(itemModel, { ...options, single }); } - -const UcList$constraints: UcConstraints> = { - deserializer: { - use: 'ListUcrxClass', - from: COMPILER_MODULE, - }, - serializer: { - use: 'ucsSupportList', - from: COMPILER_MODULE, - }, -}; diff --git a/src/schema/list/uc-multi-value.deserializer.spec.ts b/src/schema/list/uc-multi-value.deserializer.spec.ts new file mode 100644 index 00000000..5caf1d70 --- /dev/null +++ b/src/schema/list/uc-multi-value.deserializer.spec.ts @@ -0,0 +1,100 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; +import { UcdCompiler } from '../../compiler/deserialization/ucd-compiler.js'; +import { readTokens } from '../../spec/read-chunks.js'; +import { UcDeserializer } from '../uc-deserializer.js'; +import { ucNullable } from '../uc-nullable.js'; +import { ucMultiValue } from './uc-multi-value.js'; + +describe('UcMultiValue deserializer', () => { + describe('with single: as-is', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucMultiValue(Number), + }, + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('deserializes list', async () => { + await expect(readList(readTokens('1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); + }); + it('deserializes list with single item', async () => { + await expect(readList(readTokens('1,'))).resolves.toEqual([1]); + await expect(readList(readTokens(',1'))).resolves.toEqual([1]); + }); + it('deserializes empty list', async () => { + await expect(readList(readTokens(','))).resolves.toEqual([]); + }); + it('deserializes single item', async () => { + await expect(readList(readTokens('13'))).resolves.toBe(13); + }); + }); + + describe('nullable with single: as-is', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucNullable(ucMultiValue(Number)), + }, + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('deserializes null', async () => { + await expect(readList(readTokens('--'))).resolves.toBeNull(); + }); + }); + + describe('with single: prefer', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucMultiValue(Number, { single: 'prefer' }), + }, + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('deserializes list', async () => { + await expect(readList(readTokens('1 , 2, 3 '))).resolves.toEqual([1, 2, 3]); + }); + it('deserializes single list item', async () => { + await expect(readList(readTokens('1,'))).resolves.toBe(1); + await expect(readList(readTokens(',1'))).resolves.toBe(1); + }); + it('deserializes empty list', async () => { + await expect(readList(readTokens(','))).resolves.toEqual([]); + }); + it('deserializes single item', async () => { + await expect(readList(readTokens('13'))).resolves.toBe(13); + }); + }); + + describe('nullable with single: prefer', () => { + let readList: UcDeserializer; + + beforeAll(async () => { + const compiler = new UcdCompiler({ + models: { + readList: ucNullable(ucMultiValue(Number, { single: 'prefer' })), + }, + }); + + ({ readList } = await compiler.evaluate()); + }); + + it('deserializes null', async () => { + await expect(readList(readTokens('--'))).resolves.toBeNull(); + }); + }); +}); diff --git a/src/schema/list/uc-multi-value.serializer.spec.ts b/src/schema/list/uc-multi-value.serializer.spec.ts new file mode 100644 index 00000000..7b49a54c --- /dev/null +++ b/src/schema/list/uc-multi-value.serializer.spec.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, it } from '@jest/globals'; +import { UcsCompiler } from '../../compiler/serialization/ucs-compiler.js'; +import { TextOutStream } from '../../spec/text-out-stream.js'; +import { UcSerializer } from '../uc-serializer.js'; +import { ucMultiValue } from './uc-multi-value.js'; + +describe('UcMultiValue serializer', () => { + describe('with single: as-is', () => { + let writeList: UcSerializer; + + beforeAll(async () => { + const compiler = new UcsCompiler({ + models: { + writeList: ucMultiValue(Number), + }, + }); + + ({ writeList } = await compiler.evaluate()); + }); + + it('serializes list', async () => { + await expect(TextOutStream.read(async to => await writeList(to, [1, 22, 333]))).resolves.toBe( + ',1,22,333', + ); + }); + it('serializes empty list', async () => { + await expect(TextOutStream.read(async to => await writeList(to, []))).resolves.toBe(','); + }); + it('serializes list with single item as list', async () => { + await expect(TextOutStream.read(async to => await writeList(to, [13]))).resolves.toBe(',13'); + }); + it('serializes single value as is', async () => { + await expect(TextOutStream.read(async to => await writeList(to, 13))).resolves.toBe('13'); + }); + }); + + describe('with single: prefer', () => { + let writeList: UcSerializer; + + beforeAll(async () => { + const compiler = new UcsCompiler({ + models: { + writeList: ucMultiValue(Number, { single: 'prefer' }), + }, + }); + + ({ writeList } = await compiler.evaluate()); + }); + + it('serializes list', async () => { + await expect(TextOutStream.read(async to => await writeList(to, [1, 22, 333]))).resolves.toBe( + ',1,22,333', + ); + }); + it('serializes empty list', async () => { + await expect(TextOutStream.read(async to => await writeList(to, []))).resolves.toBe(','); + }); + it('serializes list with single item as single value', async () => { + await expect(TextOutStream.read(async to => await writeList(to, [13]))).resolves.toBe('13'); + }); + it('serializes single value as is', async () => { + await expect(TextOutStream.read(async to => await writeList(to, 13))).resolves.toBe('13'); + }); + }); +}); diff --git a/src/schema/list/uc-multi-value.ts b/src/schema/list/uc-multi-value.ts new file mode 100644 index 00000000..cc9b569b --- /dev/null +++ b/src/schema/list/uc-multi-value.ts @@ -0,0 +1,76 @@ +import { UcModel, UcSchema } from '../uc-schema.js'; +import { createUcListSchema } from './uc-list.impl.js'; + +/** + * Multi-value is a data represented either as JavaScript array, or as a single value. + * + * @typeParam TItem - Type of single item. + */ +export type UcMultiValue = TItem | TItem[]; + +export namespace UcMultiValue { + /** + * Schema definition for {@link UcMultiValue multi-value} serialized as list or single value. + * + * Such schema can be built with {@link ucMultiValue} function. + * + * @typeParam TItem - Type of single item. + * @typeParam TItemModel - Type of single item model. + */ + export interface Schema< + out TItem = unknown, + out TItemModel extends UcModel = UcModel, + > extends UcSchema { + readonly type: 'list'; + + /** + * Single item schema. + */ + readonly item: UcSchema.Of; + } + + /** + * Additional options for the {@link ucMultiValue multi-value schema}. + * + * @typeParam TItem - Type of single item. + * @typeParam TItemModel - Type of single item model. + */ + export interface Options = UcModel> + extends UcSchema.Extension> { + /** + * How to treat single values. + * + * One of: + * + * `'as-is'` (the default) to treat a single value as is and not convert it to array. + * `'prefer'` to treat an array with single item as single value. + * + * This option is ignored if single item type is a list itself. + */ + readonly single?: 'as-is' | 'prefer' | undefined; + } +} + +/** + * Creates data schema for {@link UcMultiValue multi-value} serialized as list or single value. + * + * @typeParam TItem - Type of single item. + * @typeParam TItemModel - Type of single item model. + * @param itemModel - Single item model. + * + * @returns New list schema instance. + */ +export function ucMultiValue = UcModel>( + itemModel: TItemModel, + options?: UcMultiValue.Options, +): UcMultiValue.Schema; + +/*#__NO_SIDE_EFFECTS__*/ +export function ucMultiValue = UcSchema>( + itemModel: UcModel, + options: UcMultiValue.Options = {}, +): UcMultiValue.Schema { + const { single = 'as-is' } = options; + + return createUcListSchema(itemModel, { ...options, single }); +} diff --git a/src/schema/map/uc-map.deserializer.spec.ts b/src/schema/map/uc-map.deserializer.spec.ts index 04437ecf..bce06abc 100644 --- a/src/schema/map/uc-map.deserializer.spec.ts +++ b/src/schema/map/uc-map.deserializer.spec.ts @@ -1,6 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; +import { UnsupportedUcSchemaError } from '../../compiler/common/unsupported-uc-schema.error.js'; import { UcdCompiler } from '../../compiler/deserialization/ucd-compiler.js'; -import { UnsupportedUcSchemaError } from '../../compiler/unsupported-uc-schema.error.js'; import { parseTokens, readTokens } from '../../spec/read-chunks.js'; import { ucList } from '../list/uc-list.js'; import { ucNumber } from '../numeric/uc-number.js'; diff --git a/src/schema/map/uc-map.serializer.spec.ts b/src/schema/map/uc-map.serializer.spec.ts index 0c39ad07..8da54cb8 100644 --- a/src/schema/map/uc-map.serializer.spec.ts +++ b/src/schema/map/uc-map.serializer.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals'; +import { UnsupportedUcSchemaError } from '../../compiler/common/unsupported-uc-schema.error.js'; import { UcsCompiler } from '../../compiler/serialization/ucs-compiler.js'; -import { UnsupportedUcSchemaError } from '../../compiler/unsupported-uc-schema.error.js'; import { TextOutStream } from '../../spec/text-out-stream.js'; import { ucList } from '../list/uc-list.js'; import { ucNullable } from '../uc-nullable.js';