From 5f46560e70b359fd4716fcdb8417450cb1cc9ff1 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Sun, 11 Dec 2022 15:34:41 -0800 Subject: [PATCH 1/2] chore: and .includes to schemas --- packages/validator/src/schema/link.js | 9 + packages/validator/src/schema/schema.js | 231 +++++++++++++++++++++--- packages/validator/src/schema/type.ts | 30 ++- packages/validator/src/schema/uri.js | 13 ++ 4 files changed, 251 insertions(+), 32 deletions(-) diff --git a/packages/validator/src/schema/link.js b/packages/validator/src/schema/link.js index 80dd34c1..4f6ad84c 100644 --- a/packages/validator/src/schema/link.js +++ b/packages/validator/src/schema/link.js @@ -55,6 +55,15 @@ class LinkSchema extends Schema.API { } } } + + /** + * + * @param {API.Link} self + * @param {API.Link} other + */ + incudesWith(self, other) { + return self.equals(other) + } } /** @type {Schema.Schema, unknown>} */ diff --git a/packages/validator/src/schema/schema.js b/packages/validator/src/schema/schema.js index c952fde8..f9182908 100644 --- a/packages/validator/src/schema/schema.js +++ b/packages/validator/src/schema/schema.js @@ -32,6 +32,7 @@ export class API { readWith(input, settings) { throw new Error(`Abstract method readWith must be implemented by subclass`) } + /** * @param {I} input * @returns {Schema.ReadResult} @@ -40,6 +41,23 @@ export class API { return this.readWith(input, this.settings) } + /** + * @param {T} self + * @param {T} other + */ + includes(self, other) { + return this.includesWith(self, other, this.settings) + } + + /** + * @param {T} self + * @param {T} other + * @param {Settings} _settings + */ + includesWith(self, other, _settings) { + return self === other + } + /** * @param {unknown} value * @returns {value is T} @@ -83,7 +101,7 @@ export class API { } /** * @template U - * @param {Schema.Reader} schema + * @param {Schema.GroupReader} schema * @returns {Schema.Schema} */ @@ -93,7 +111,7 @@ export class API { /** * @template U - * @param {Schema.Reader} schema + * @param {Schema.GroupReader} schema * @returns {Schema.Schema} */ and(schema) { @@ -132,7 +150,7 @@ export class API { } const schema = new Default({ - reader: /** @type {Schema.Reader} */ (this), + reader: /** @type {Schema.GroupReader} */ (this), value: /** @type {Schema.NotUndefined} */ (fallback), }) @@ -158,6 +176,11 @@ class Never extends API { read(input) { return typeError({ expect: 'never', actual: input }) } + + includes() { + // never is included in any other group + return true + } } /** @@ -192,7 +215,7 @@ export const unknown = () => new Unknown() /** * @template O * @template [I=unknown] - * @extends {API>} + * @extends {API>} * @implements {Schema.Schema} */ class Nullable extends API { @@ -215,12 +238,26 @@ class Nullable extends API { toString() { return `${this.settings}.nullable()` } + + /** + * + * @param {null|O} self + * @param {null|O} other + * @param {Schema.Group} group + */ + includesWith(self, other, group) { + return self === other + ? true + : self === null || other === null + ? false + : group.includes(self, other) + } } /** * @template O * @template [I=unknown] - * @param {Schema.Reader} schema + * @param {Schema.GroupReader} schema * @returns {Schema.Schema} */ export const nullable = schema => new Nullable(schema) @@ -228,7 +265,7 @@ export const nullable = schema => new Nullable(schema) /** * @template O * @template [I=unknown] - * @extends {API>} + * @extends {API>} * @implements {Schema.Schema} */ class Optional extends API { @@ -247,12 +284,26 @@ class Optional extends API { toString() { return `${this.settings}.optional()` } + + /** + * + * @param {O|undefined} self + * @param {O|undefined} other + * @param {Schema.Group} group + */ + includesWith(self, other, group) { + return self === undefined + ? true + : other === undefined + ? false + : group.includes(self, other) + } } /** * @template {unknown} O * @template [I=unknown] - * @extends {API, value:O & Schema.NotUndefined}>} + * @extends {API, value:O & Schema.NotUndefined}>} * @implements {Schema.DefaultSchema} */ class Default extends API { @@ -283,6 +334,18 @@ class Default extends API { ) } } + + /** + * + * @param {O} self + * @param {O} other + * @param {object} options + * @param {Schema.Group} options.reader + */ + includesWith(self, other, { reader: group }) { + return group.includes(self, other) + } + toString() { return `${this.settings.reader}.default(${JSON.stringify( this.settings.value @@ -297,7 +360,7 @@ class Default extends API { /** * @template O * @template [I=unknown] - * @param {Schema.Reader} schema + * @param {Schema.GroupReader} schema * @returns {Schema.Schema} */ export const optional = schema => new Optional(schema) @@ -305,7 +368,7 @@ export const optional = schema => new Optional(schema) /** * @template O * @template [I=unknown] - * @extends {API>} + * @extends {API>} * @implements {Schema.ArraySchema} */ class ArrayOf extends API { @@ -329,6 +392,34 @@ class ArrayOf extends API { } return results } + + /** + * + * @param {O[]} self + * @param {O[]} other + * @param {Schema.Group} group + */ + includesWith(self, other, group) { + // If arrays are of different length it is not obvious which semantics to + // apply. With set semantics array with more elements includes array with + // subset of it's elements. With structural (sub-typing) semantics structure + // with subset of elements would include structure with superset of elements. + // Here we choose treat arrays with different length as disjoint sets, so + // neither of two will include the other, if arrays have same length then + // one includes the other if each element includes the same positioned + // element of the other. + if (self.length === other.length) { + for (const [index, element] of other.entries()) { + if (!group.includes(self[index], element)) { + return false + } + } + return true + } else { + return false + } + } + get element() { return this.settings } @@ -340,13 +431,13 @@ class ArrayOf extends API { /** * @template O * @template [I=unknown] - * @param {Schema.Reader} schema + * @param {Schema.GroupReader} schema * @returns {Schema.ArraySchema} */ export const array = schema => new ArrayOf(schema) /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] * @extends {API, I, U>} @@ -381,6 +472,21 @@ class Tuple extends API { return /** @type {Schema.InferTuple} */ (results) } + /** + * + * @param {Schema.InferTuple} self + * @param {Schema.InferTuple} other + * @param {U} shape + */ + includesWith(self, other, shape) { + for (const [index, group] of shape.entries()) { + if (!group.includes(self[index], other[index])) { + return false + } + } + return true + } + /** @type {U} */ get shape() { return this.settings @@ -392,7 +498,7 @@ class Tuple extends API { } /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} shape @@ -439,7 +545,7 @@ const createEnum = variants => export { createEnum as enum } /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] * @extends {API, I, U>} @@ -469,10 +575,28 @@ class Union extends API { toString() { return `union([${this.variants.map(type => type.toString()).join(', ')}])` } + + /** + * @param {Schema.InferUnion} self + * @param {Schema.InferUnion} other + * @param {U} variants + */ + includesWith(self, other, variants) { + for (const group of variants) { + // try catch here is really uncomfortable, but unfortunately right now + // we have no better way because `self` maybe of variant A and `other` may + // be of variant B and since we don't know which one is which there is + // no way for us to compare. + try { + return group.includes(self, other) + } catch {} + } + return false + } } /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants @@ -483,24 +607,24 @@ const union = variants => new Union(variants) /** * @template T, U * @template [I=unknown] - * @param {Schema.Reader} left - * @param {Schema.Reader} right + * @param {Schema.GroupReader} left + * @param {Schema.GroupReader} right * @returns {Schema.Schema} */ export const or = (left, right) => union([left, right]) /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] - * @extends {API, I, U>} - * @implements {Schema.Schema, I>} + * @extends {API, I, U>} + * @implements {Schema.Schema, I>} */ class Intersection extends API { /** * @param {I} input * @param {U} schemas - * @returns {Schema.ReadResult>} + * @returns {Schema.ReadResult>} */ readWith(input, schemas) { const causes = [] @@ -513,7 +637,21 @@ class Intersection extends API { return causes.length > 0 ? new IntersectionError({ causes }) - : /** @type {Schema.ReadResult>} */ (input) + : /** @type {Schema.ReadResult>} */ (input) + } + + /** + * @param {Schema.InferIntersection} self + * @param {Schema.InferIntersection} other + * @param {U} schemas + */ + includesWith(self, other, schemas) { + for (const group of schemas) { + if (!group.includes(self, other)) { + return false + } + } + return true } toString() { return `intersection([${this.settings @@ -523,19 +661,19 @@ class Intersection extends API { } /** - * @template {Schema.Reader} T + * @template {Schema.GroupReader} T * @template {[T, ...T[]]} U * @template [I=unknown] * @param {U} variants - * @returns {Schema.Schema, I>} + * @returns {Schema.Schema, I>} */ export const intersection = variants => new Intersection(variants) /** * @template T, U * @template [I=unknown] - * @param {Schema.Reader} left - * @param {Schema.Reader} right + * @param {Schema.GroupReader} left + * @param {Schema.GroupReader} right * @returns {Schema.Schema} */ export const and = (left, right) => intersection([left, right]) @@ -600,6 +738,15 @@ class UnknownNumber extends API { refine(schema) { return new RefinedNumber({ base: this, schema }) } + + /** + * + * @param {O} self + * @param {O} other + */ + includesWith(self, other) { + return self >= other + } } /** @@ -783,6 +930,14 @@ class UnknownString extends API { endsWith(suffix) { return this.refine(endsWith(suffix)) } + + /** + * @param {O} self + * @param {O} other + */ + includesWith(self, other) { + return self.includes(other) + } toString() { return `string()` } @@ -968,7 +1123,7 @@ class Literal extends API { export const literal = value => new Literal(value) /** - * @template {{[key:string]: Schema.Reader}} U + * @template {{[key:string]: Schema.GroupReader}} U * @template [I=unknown] * @extends {API, I, U>} */ @@ -1008,6 +1163,22 @@ class Struct extends API { return struct } + /** + * @param {Schema.InferStruct} self + * @param {Schema.InferStruct} other + * @param {U} shape + */ + includesWith(self, other, shape) { + for (const [key, group] of Object.entries(shape)) { + const name = /** @type {keyof self} */ (key) + if (!group.includes(self[name], other[name])) { + return false + } + } + + return true + } + /** @type {U} */ get shape() { // @ts-ignore - We declared `settings` private but we access it here @@ -1043,16 +1214,16 @@ class Struct extends API { /** * @template {null|boolean|string|number} T - * @template {{[key:string]: T|Schema.Reader}} U - * @template {{[K in keyof U]: U[K] extends Schema.Reader ? U[K] : Schema.LiteralSchema}} V + * @template {{[key:string]: T|Schema.GroupReader}} U + * @template {{[K in keyof U]: U[K] extends Schema.GroupReader ? U[K] : Schema.LiteralSchema}} V * @template [I=unknown] * @param {U} fields * @returns {Schema.StructSchema} */ export const struct = fields => { const shape = - /** @type {{[K in keyof U]: Schema.Reader}} */ ({}) - /** @type {[keyof U & string, T|Schema.Reader][]} */ + /** @type {{[K in keyof U]: Schema.GroupReader}} */ ({}) + /** @type {[keyof U & string, T|Schema.GroupReader][]} */ const entries = Object.entries(fields) for (const [key, field] of entries) { diff --git a/packages/validator/src/schema/type.ts b/packages/validator/src/schema/type.ts index 0bce1e55..c91086be 100644 --- a/packages/validator/src/schema/type.ts +++ b/packages/validator/src/schema/type.ts @@ -8,6 +8,27 @@ export interface Reader< read(input: I): Result } +export interface GroupReader< + O = unknown, + I = unknown, + X extends { error: true } = Error, + Q = O +> extends Reader, + Group {} + +export interface Group { + /** + * Returns `true` if given `group` contains `members`. + */ + includes(group: I, members: T): boolean +} + +export interface DerivedSchema + extends Reader, + Group { + derivedFrom: GroupReader +} + export type { Error } export type ReadResult = Result @@ -15,7 +36,8 @@ export type ReadResult = Result export interface Schema< O extends unknown = unknown, I extends unknown = unknown -> extends Reader { +> extends Reader, + Group { optional(): Schema nullable(): Schema array(): Schema @@ -28,6 +50,10 @@ export interface Schema< is(value: unknown): value is O from(value: I): O + + derive( + schema: GroupReader + ): DerivedSchema } export interface DefaultSchema< @@ -95,7 +121,7 @@ export type Float = number & Phantom<{ typeof: 'float' }> export type Infer = T extends Reader ? T : never -export type InferIntesection = { +export type InferIntersection = { [K in keyof U]: (input: Infer) => void }[number] extends (input: infer T) => void ? T diff --git a/packages/validator/src/schema/uri.js b/packages/validator/src/schema/uri.js index 64796640..6256aaa0 100644 --- a/packages/validator/src/schema/uri.js +++ b/packages/validator/src/schema/uri.js @@ -34,6 +34,19 @@ class URISchema extends Schema.API { return Schema.error(`Invalid URI`) } } + + /** + * + * @param {API.URI} self + * @param {API.URI} other + */ + includes(self, other) { + return self === other + ? true + : // Ensure that `self` has trailing space so that + // file://a doesn't include file://ab/c + other.startsWith(self.endsWith('/') ? self : `${self}/`) + } } const schema = new URISchema({}) From ba8ebe16ce114a24ee2a8d9515e5571315013a83 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Tue, 7 Feb 2023 19:46:14 -0800 Subject: [PATCH 2/2] chore: add some tests --- packages/validator/src/schema/schema.js | 57 ++++++++++++++++++- packages/validator/src/schema/type.ts | 8 ++- .../validator/test/schema-includes.spec.js | 27 +++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 packages/validator/test/schema-includes.spec.js diff --git a/packages/validator/src/schema/schema.js b/packages/validator/src/schema/schema.js index f9182908..03c04373 100644 --- a/packages/validator/src/schema/schema.js +++ b/packages/validator/src/schema/schema.js @@ -2,6 +2,17 @@ import * as Schema from './type.js' export * from './type.js' +const defaultGroup = { + /** + * + * @param {unknown} group + * @param {unknown} member + * @returns {boolean} + */ + includes(group, member) { + return group == member + }, +} /** * @abstract * @template [T=unknown] @@ -13,8 +24,9 @@ export * from './type.js' export class API { /** * @param {Settings} settings + * @param {Schema.Group} group */ - constructor(settings) { + constructor(settings, group = defaultGroup) { /** @protected */ this.settings = settings } @@ -127,6 +139,19 @@ export class API { return refine(this, schema) } + /** + * @param {Schema.Group} group + * @returns {Schema.Schema} + */ + with(group) { + return /** @type {Schema.Schema} */ ( + new SchemaWithGroup({ + base: this, + group, + }) + ) + } + /** * @template {string} Kind * @param {Kind} [kind] @@ -181,6 +206,9 @@ class Never extends API { // never is included in any other group return true } + with() { + return this + } } /** @@ -1072,6 +1100,33 @@ class Refine extends API { } } +/** + * @template T + * @template [I=unknown] + * @extends {API, group: Schema.Group }>} + * @implements {Schema.Schema} + */ + +class SchemaWithGroup extends API { + /** + * @param {I} input + * @param {{ base: Schema.Reader }} settings + */ + readWith(input, { base }) { + return base.read(input) + } + toString() { + return `${this.settings.base})` + } + /** + * @param {T} group + * @param {T} member + */ + includes(group, member) { + return this.settings.group.includes(group, member) + } +} + /** * @template T * @template {T} U diff --git a/packages/validator/src/schema/type.ts b/packages/validator/src/schema/type.ts index c91086be..25cff8f9 100644 --- a/packages/validator/src/schema/type.ts +++ b/packages/validator/src/schema/type.ts @@ -51,9 +51,11 @@ export interface Schema< is(value: unknown): value is O from(value: I): O - derive( - schema: GroupReader - ): DerivedSchema + with(options: Group): Schema + + // derive( + // schema: GroupReader + // ): DerivedSchema } export interface DefaultSchema< diff --git a/packages/validator/test/schema-includes.spec.js b/packages/validator/test/schema-includes.spec.js new file mode 100644 index 00000000..9710deb2 --- /dev/null +++ b/packages/validator/test/schema-includes.spec.js @@ -0,0 +1,27 @@ +import { test, assert } from './test.js' +import * as Schema from '../src/schema.js' +import fixtures from './schema/fixtures.js' + +test('includes on strings', () => { + const text = Schema.string() + assert.equal(text.includes('hello', 'world'), false) + assert.equal(text.includes('hello', 'hello'), true) + assert.equal(text.includes('hello world', 'hello'), true) +}) + +test('path includes', () => { + /** + * @param {string} path + */ + const withTrainingDelimiter = path => (path.endsWith('/') ? path : `${path}/`) + + const path = Schema.string().with({ + includes(parent, child) { + return parent === child || child.startsWith(withTrainingDelimiter(parent)) + }, + }) + + assert.equal(path.includes('/foo/bar', '/foo'), false) + assert.equal(path.includes('/foo', '/foo/bar'), true) + assert.equal(path.includes('/fo', '/foo/bar'), false) +})