feat: Return type annotations #53
4 changed files with 77 additions and 65 deletions
|
@ -2,7 +2,7 @@
|
||||||
import typed from 'typed-function'
|
import typed from 'typed-function'
|
||||||
import {makeChain} from './Chain.mjs'
|
import {makeChain} from './Chain.mjs'
|
||||||
import {dependencyExtractor, generateTypeExtractor} from './extractors.mjs'
|
import {dependencyExtractor, generateTypeExtractor} from './extractors.mjs'
|
||||||
import {R_, returnTypeOf} from './returns.mjs'
|
import {Returns, returnTypeOf} from './Returns.mjs'
|
||||||
import {typeListOfSignature, typesOfSignature, subsetOfKeys} from './utils.mjs'
|
import {typeListOfSignature, typesOfSignature, subsetOfKeys} from './utils.mjs'
|
||||||
|
|
||||||
const anySpec = {} // fixed dummy specification of 'any' type
|
const anySpec = {} // fixed dummy specification of 'any' type
|
||||||
|
@ -13,7 +13,9 @@ const templateFromParam = 'U' // For defining covariant conversions
|
||||||
/* Returns a new signature just like sig but with the parameter replaced by
|
/* Returns a new signature just like sig but with the parameter replaced by
|
||||||
* the type
|
* the type
|
||||||
*/
|
*/
|
||||||
function substituteInSig(sig, parameter, type) {
|
const upperBounds = /\s*[(]=\s*(\w*)\s*/g
|
||||||
|
function substituteInSignature(signature, parameter, type) {
|
||||||
|
const sig = signature.replaceAll(upperBounds, '')
|
||||||
const pattern = new RegExp("\\b" + parameter + "\\b", 'g')
|
const pattern = new RegExp("\\b" + parameter + "\\b", 'g')
|
||||||
return sig.replaceAll(pattern, type)
|
return sig.replaceAll(pattern, type)
|
||||||
}
|
}
|
||||||
|
@ -30,12 +32,14 @@ export default class PocomathInstance {
|
||||||
'install',
|
'install',
|
||||||
'installType',
|
'installType',
|
||||||
'instantiateTemplate',
|
'instantiateTemplate',
|
||||||
|
'isPriorTo',
|
||||||
'isSubtypeOf',
|
'isSubtypeOf',
|
||||||
'joinTypes',
|
'joinTypes',
|
||||||
'name',
|
'name',
|
||||||
'returnTypeOf',
|
'returnTypeOf',
|
||||||
'self',
|
'self',
|
||||||
'subtypesOf',
|
'subtypesOf',
|
||||||
|
'supertypesOf',
|
||||||
'Templates',
|
'Templates',
|
||||||
'typeOf',
|
'typeOf',
|
||||||
'Types',
|
'Types',
|
||||||
|
@ -88,7 +92,7 @@ export default class PocomathInstance {
|
||||||
|
|
||||||
this._installFunctions({
|
this._installFunctions({
|
||||||
typeOf: {
|
typeOf: {
|
||||||
ground: {uses: new Set(), does: () => R_('string', () => 'any')}
|
ground: {uses: new Set(), does: () => Returns('string', () => 'any')}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -202,7 +206,7 @@ export default class PocomathInstance {
|
||||||
}
|
}
|
||||||
if (!(this._typed.isTypedFunction(operation))) return 'any'
|
if (!(this._typed.isTypedFunction(operation))) return 'any'
|
||||||
const details = this._typed.findSignature(operation, signature)
|
const details = this._typed.findSignature(operation, signature)
|
||||||
return returnTypeOf(details.fn)
|
return returnTypeOf(details.fn, signature, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Return a chain object for this instance with a given value: */
|
/* Return a chain object for this instance with a given value: */
|
||||||
|
@ -398,7 +402,7 @@ export default class PocomathInstance {
|
||||||
}
|
}
|
||||||
// update the typeOf function
|
// update the typeOf function
|
||||||
const imp = {}
|
const imp = {}
|
||||||
imp[type] = {uses: new Set(), does: () => R_('string', () => type)}
|
imp[type] = {uses: new Set(), does: () => Returns('string', () => type)}
|
||||||
this._installFunctions({typeOf: imp})
|
this._installFunctions({typeOf: imp})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -412,18 +416,36 @@ export default class PocomathInstance {
|
||||||
supSubs.splice(i, 0, sub)
|
supSubs.splice(i, 0, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Returns true is typeA is a subtype of type B */
|
/* Returns true if typeA is a subtype of type B */
|
||||||
isSubtypeOf(typeA, typeB) {
|
isSubtypeOf(typeA, typeB) {
|
||||||
return this._subtypes[typeB].includes(typeA)
|
return this._subtypes[typeB].includes(typeA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Returns true if typeA is a subtype of or converts to type B */
|
||||||
|
isPriorTo(typeA, typeB) {
|
||||||
|
if (!(typeB in this._priorTypes)) return false
|
||||||
|
return this._priorTypes[typeB].has(typeA)
|
||||||
|
}
|
||||||
|
|
||||||
/* Returns a list of the subtypes of a given type, in topological sorted
|
/* Returns a list of the subtypes of a given type, in topological sorted
|
||||||
* order (i.e, no type on the list contains one that comes after it).
|
* order (i.e, no type on the list contains one that comes after it).
|
||||||
*/
|
*/
|
||||||
subtypesOf(type) {
|
subtypesOf(type) {
|
||||||
// HERE! For this to work, have to maintain subtypes as a sorted list.
|
|
||||||
return this._subtypes[type] // should we clone?
|
return this._subtypes[type] // should we clone?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Returns a list of the supertypes of a given type, starting with itself,
|
||||||
|
* in topological order
|
||||||
|
*/
|
||||||
|
supertypesOf(type) {
|
||||||
|
const supList = []
|
||||||
|
while (type) {
|
||||||
|
supList.push(type)
|
||||||
|
type = this.Types[type].refines
|
||||||
|
}
|
||||||
|
return supList
|
||||||
|
}
|
||||||
|
|
||||||
/* Returns the most refined type containing all the types in the array,
|
/* Returns the most refined type containing all the types in the array,
|
||||||
* with '' standing for the empty type for convenience. If the second
|
* with '' standing for the empty type for convenience. If the second
|
||||||
* argument `convert` is true, a convertible type is considered a
|
* argument `convert` is true, a convertible type is considered a
|
||||||
|
@ -488,7 +510,7 @@ export default class PocomathInstance {
|
||||||
const imp = {}
|
const imp = {}
|
||||||
imp[type] = {
|
imp[type] = {
|
||||||
uses: new Set(['T']),
|
uses: new Set(['T']),
|
||||||
does: ({T}) => R_('string', () => `${base}<${T}>`)
|
does: ({T}) => Returns('string', () => `${base}<${T}>`)
|
||||||
}
|
}
|
||||||
this._installFunctions({typeOf: imp})
|
this._installFunctions({typeOf: imp})
|
||||||
|
|
||||||
|
@ -628,7 +650,7 @@ export default class PocomathInstance {
|
||||||
/* Check if it's an ordinary non-template signature */
|
/* Check if it's an ordinary non-template signature */
|
||||||
let explicit = true
|
let explicit = true
|
||||||
for (const type of typesOfSignature(rawSignature)) {
|
for (const type of typesOfSignature(rawSignature)) {
|
||||||
for (const word of type.split(/[<>]/)) {
|
for (const word of type.split(/[<>(=\s]/)) {
|
||||||
if (this._templateParam(word)) {
|
if (this._templateParam(word)) {
|
||||||
explicit = false
|
explicit = false
|
||||||
break
|
break
|
||||||
|
@ -640,14 +662,26 @@ export default class PocomathInstance {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
/* It's a template, have to instantiate */
|
/* It's a template, have to instantiate */
|
||||||
|
/* First, find any upper bounds on the instantation */
|
||||||
|
/* TODO: handle multiple upper bounds */
|
||||||
|
upperBounds.lastIndex = 0
|
||||||
|
let ubType = upperBounds.exec(rawSignature)
|
||||||
|
if (ubType) {
|
||||||
|
ubType = ubType[1]
|
||||||
|
if (!ubType in this.Types) {
|
||||||
|
throw new TypeError(
|
||||||
|
`Unknown type upper bound '${ubType}' in '${rawSignature}'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
/* First, add the known instantiations, gathering all types needed */
|
/* First, add the known instantiations, gathering all types needed */
|
||||||
if (!('instantiations' in behavior)) {
|
if (!('instantiations' in behavior)) {
|
||||||
behavior.instantiations = new Set()
|
behavior.instantiations = new Set()
|
||||||
|
if (ubType) behavior.instantiations.add(ubType)
|
||||||
}
|
}
|
||||||
let instantiationSet = new Set()
|
let instantiationSet = new Set()
|
||||||
for (const instType of behavior.instantiations) {
|
for (const instType of behavior.instantiations) {
|
||||||
instantiationSet.add(instType)
|
instantiationSet.add(instType)
|
||||||
for (const other of this._priorTypes[instType]) {
|
for (const other of this.subtypesOf(instType)) {
|
||||||
instantiationSet.add(other)
|
instantiationSet.add(other)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -656,7 +690,7 @@ export default class PocomathInstance {
|
||||||
if (!(instType in this.Types)) continue
|
if (!(instType in this.Types)) continue
|
||||||
if (this.Types[instType] === anySpec) continue
|
if (this.Types[instType] === anySpec) continue
|
||||||
const signature =
|
const signature =
|
||||||
substituteInSig(rawSignature, theTemplateParam, instType)
|
substituteInSignature(rawSignature, theTemplateParam, instType)
|
||||||
/* Don't override an explicit implementation: */
|
/* Don't override an explicit implementation: */
|
||||||
if (signature in imps) continue
|
if (signature in imps) continue
|
||||||
/* Don't go too deep */
|
/* Don't go too deep */
|
||||||
|
@ -670,7 +704,7 @@ export default class PocomathInstance {
|
||||||
const uses = new Set()
|
const uses = new Set()
|
||||||
for (const dep of behavior.uses) {
|
for (const dep of behavior.uses) {
|
||||||
if (this._templateParam(dep)) continue
|
if (this._templateParam(dep)) continue
|
||||||
uses.add(substituteInSig(dep, theTemplateParam, instType))
|
uses.add(substituteInSignature(dep, theTemplateParam, instType))
|
||||||
}
|
}
|
||||||
const patch = (refs) => {
|
const patch = (refs) => {
|
||||||
const innerRefs = {}
|
const innerRefs = {}
|
||||||
|
@ -678,7 +712,7 @@ export default class PocomathInstance {
|
||||||
if (this._templateParam(dep)) {
|
if (this._templateParam(dep)) {
|
||||||
innerRefs[dep] = instType
|
innerRefs[dep] = instType
|
||||||
} else {
|
} else {
|
||||||
const outerName = substituteInSig(
|
const outerName = substituteInSignature(
|
||||||
dep, theTemplateParam, instType)
|
dep, theTemplateParam, instType)
|
||||||
innerRefs[dep] = refs[outerName]
|
innerRefs[dep] = refs[outerName]
|
||||||
}
|
}
|
||||||
|
@ -689,11 +723,13 @@ export default class PocomathInstance {
|
||||||
tf_imps, signature, {uses, does: patch})
|
tf_imps, signature, {uses, does: patch})
|
||||||
}
|
}
|
||||||
/* Now add the catchall signature */
|
/* Now add the catchall signature */
|
||||||
|
/* (Not needed if if it's a bounded template) */
|
||||||
|
if (ubType) continue
|
||||||
let templateCall = `<${theTemplateParam}>`
|
let templateCall = `<${theTemplateParam}>`
|
||||||
/* Relying here that the base of 'Foo<T>' is 'Foo': */
|
/* Relying here that the base of 'Foo<T>' is 'Foo': */
|
||||||
let baseSignature = rawSignature.replaceAll(templateCall, '')
|
let baseSignature = rawSignature.replaceAll(templateCall, '')
|
||||||
/* Any remaining template params are top-level */
|
/* Any remaining template params are top-level */
|
||||||
const signature = substituteInSig(
|
const signature = substituteInSignature(
|
||||||
baseSignature, theTemplateParam, 'ground')
|
baseSignature, theTemplateParam, 'ground')
|
||||||
/* The catchall signature has to detect the actual type of the call
|
/* The catchall signature has to detect the actual type of the call
|
||||||
* and add the new instantiations.
|
* and add the new instantiations.
|
||||||
|
@ -718,7 +754,8 @@ export default class PocomathInstance {
|
||||||
for (const dep of behavior.uses) {
|
for (const dep of behavior.uses) {
|
||||||
let [func, needsig] = dep.split(/[()]/)
|
let [func, needsig] = dep.split(/[()]/)
|
||||||
if (needsig) {
|
if (needsig) {
|
||||||
const subsig = substituteInSig(needsig, theTemplateParam, '')
|
const subsig = substituteInSignature(
|
||||||
|
needsig, theTemplateParam, '')
|
||||||
if (subsig === needsig) {
|
if (subsig === needsig) {
|
||||||
simplifiedUses[dep] = dep
|
simplifiedUses[dep] = dep
|
||||||
} else {
|
} else {
|
||||||
|
@ -789,7 +826,7 @@ export default class PocomathInstance {
|
||||||
self._maxDepthSeen = depth
|
self._maxDepthSeen = depth
|
||||||
}
|
}
|
||||||
/* Generate the list of actual wanted types */
|
/* Generate the list of actual wanted types */
|
||||||
const wantTypes = parTypes.map(type => substituteInSig(
|
const wantTypes = parTypes.map(type => substituteInSignature(
|
||||||
type, theTemplateParam, instantiateFor))
|
type, theTemplateParam, instantiateFor))
|
||||||
/* Now we have to add any actual types that are relevant
|
/* Now we have to add any actual types that are relevant
|
||||||
* to this invocation. Namely, that would be every formal parameter
|
* to this invocation. Namely, that would be every formal parameter
|
||||||
|
@ -811,7 +848,7 @@ export default class PocomathInstance {
|
||||||
if (wantType.slice(0,3) === '...') {
|
if (wantType.slice(0,3) === '...') {
|
||||||
wantType = wantType.slice(3)
|
wantType = wantType.slice(3)
|
||||||
}
|
}
|
||||||
wantType = substituteInSig(
|
wantType = substituteInSignature(
|
||||||
wantType, theTemplateParam, instantiateFor)
|
wantType, theTemplateParam, instantiateFor)
|
||||||
if (wantType !== parTypes[i]) {
|
if (wantType !== parTypes[i]) {
|
||||||
args[j] = self._typed.convert(args[j], wantType)
|
args[j] = self._typed.convert(args[j], wantType)
|
||||||
|
@ -840,7 +877,7 @@ export default class PocomathInstance {
|
||||||
} else {
|
} else {
|
||||||
let [func, needsig] = dep.split(/[()]/)
|
let [func, needsig] = dep.split(/[()]/)
|
||||||
if (self._typed.isTypedFunction(refs[simplifiedDep])) {
|
if (self._typed.isTypedFunction(refs[simplifiedDep])) {
|
||||||
const subsig = substituteInSig(
|
const subsig = substituteInSignature(
|
||||||
needsig, theTemplateParam, instantiateFor)
|
needsig, theTemplateParam, instantiateFor)
|
||||||
let resname = simplifiedDep
|
let resname = simplifiedDep
|
||||||
if (resname == 'self') resname = name
|
if (resname == 'self') resname = name
|
||||||
|
@ -890,8 +927,8 @@ export default class PocomathInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adapts Pocomath-style behavior specification (uses, does) for signature
|
/* Adapts Pocomath-style behavior specification (uses, does) for signature
|
||||||
* to typed-function implementations and inserts the result into plain object
|
* to typed-function implementations and inserts the result into plain
|
||||||
* imps
|
* object imps
|
||||||
*/
|
*/
|
||||||
_addTFimplementation(imps, signature, behavior) {
|
_addTFimplementation(imps, signature, behavior) {
|
||||||
const {uses, does} = behavior
|
const {uses, does} = behavior
|
||||||
|
@ -910,7 +947,7 @@ export default class PocomathInstance {
|
||||||
* Verify that the desired signature has been fully grounded:
|
* Verify that the desired signature has been fully grounded:
|
||||||
*/
|
*/
|
||||||
if (needsig) {
|
if (needsig) {
|
||||||
const trysig = substituteInSig(needsig, theTemplateParam, '')
|
const trysig = substituteInSignature(needsig, theTemplateParam, '')
|
||||||
if (trysig !== needsig) {
|
if (trysig !== needsig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Attempt to add a template implementation: ' +
|
'Attempt to add a template implementation: ' +
|
||||||
|
@ -1051,8 +1088,9 @@ export default class PocomathInstance {
|
||||||
return resultingTypes
|
return resultingTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Maybe add the instantiation of template type base with argument tyoe
|
/* Maybe add the instantiation of template type base with argument type
|
||||||
* instantiator to the Types of this instance, if it hasn't happened already.
|
* instantiator to the Types of this instance, if it hasn't happened
|
||||||
|
* already.
|
||||||
* Returns the name of the type if added, false if it was already there,
|
* Returns the name of the type if added, false if it was already there,
|
||||||
* and undefined if the type is declined (because of being nested too deep).
|
* and undefined if the type is declined (because of being nested too deep).
|
||||||
*/
|
*/
|
||||||
|
@ -1081,7 +1119,7 @@ export default class PocomathInstance {
|
||||||
if (template.before) {
|
if (template.before) {
|
||||||
for (const beforeTmpl of template.before) {
|
for (const beforeTmpl of template.before) {
|
||||||
beforeTypes.push(
|
beforeTypes.push(
|
||||||
substituteInSig(beforeTmpl, theTemplateParam, instantiator))
|
substituteInSignature(beforeTmpl, theTemplateParam, instantiator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (beforeTypes.length > 0) {
|
if (beforeTypes.length > 0) {
|
||||||
|
@ -1090,7 +1128,7 @@ export default class PocomathInstance {
|
||||||
newTypeSpec.test = template.test(this._typeTests[instantiator])
|
newTypeSpec.test = template.test(this._typeTests[instantiator])
|
||||||
if (template.from) {
|
if (template.from) {
|
||||||
for (let source in template.from) {
|
for (let source in template.from) {
|
||||||
const instSource = substituteInSig(
|
const instSource = substituteInSignature(
|
||||||
source, theTemplateParam, instantiator)
|
source, theTemplateParam, instantiator)
|
||||||
let usesFromParam = false
|
let usesFromParam = false
|
||||||
for (const word of instSource.split(/[<>]/)) {
|
for (const word of instSource.split(/[<>]/)) {
|
||||||
|
@ -1101,14 +1139,14 @@ export default class PocomathInstance {
|
||||||
}
|
}
|
||||||
if (usesFromParam) {
|
if (usesFromParam) {
|
||||||
for (const iFrom in instantiatorSpec.from) {
|
for (const iFrom in instantiatorSpec.from) {
|
||||||
const finalSource = substituteInSig(
|
const finalSource = substituteInSignature(
|
||||||
instSource, templateFromParam, iFrom)
|
instSource, templateFromParam, iFrom)
|
||||||
maybeFrom[finalSource] = template.from[source](
|
maybeFrom[finalSource] = template.from[source](
|
||||||
instantiatorSpec.from[iFrom])
|
instantiatorSpec.from[iFrom])
|
||||||
}
|
}
|
||||||
// Assuming all templates are covariant here, I guess...
|
// Assuming all templates are covariant here, I guess...
|
||||||
for (const subType of this._subtypes[instantiator]) {
|
for (const subType of this._subtypes[instantiator]) {
|
||||||
const finalSource = substituteInSig(
|
const finalSource = substituteInSignature(
|
||||||
instSource, templateFromParam, subType)
|
instSource, templateFromParam, subType)
|
||||||
maybeFrom[finalSource] = template.from[source](x => x)
|
maybeFrom[finalSource] = template.from[source](x => x)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,15 +15,20 @@ function cloneFunction(fn) {
|
||||||
return theClone
|
return theClone
|
||||||
}
|
}
|
||||||
|
|
||||||
export function R_(type, fn) {
|
export function Returns(type, fn) {
|
||||||
if ('returns' in fn) fn = cloneFunction(fn)
|
if ('returns' in fn) fn = cloneFunction(fn)
|
||||||
fn.returns = type
|
fn.returns = type
|
||||||
return fn
|
return fn
|
||||||
}
|
}
|
||||||
|
|
||||||
export function returnTypeOf(fn) {
|
export function returnTypeOf(fn, signature, pmInstance) {
|
||||||
return fn.returns || 'any'
|
const typeOfReturns = typeof fn.returns
|
||||||
|
if (typeOfReturns === 'undefined') return 'any'
|
||||||
|
if (typeOfReturns === 'string') return fn.returns
|
||||||
|
// not sure if we will need a function to determine the return type,
|
||||||
|
// but allow it for now:
|
||||||
|
return fn.returns(signature, pmInstance)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default R_
|
export default Returns
|
||||||
|
|
|
@ -1,37 +1,6 @@
|
||||||
import R_ from '../core/returns.mjs'
|
import Returns from '../core/Returns.mjs'
|
||||||
export * from './Types/number.mjs'
|
export * from './Types/number.mjs'
|
||||||
|
|
||||||
export const negate = {
|
export const negate = {
|
||||||
NumInt: () => R_('NumInt', n => -n),
|
'T (= number': ({T}) => Returns(T, n => -n)
|
||||||
number: () => R_('number', n => -n)
|
|
||||||
}
|
}
|
||||||
/* Can we find a mechanism to avoid reiterating the definition
|
|
||||||
for each number (sub)type? Maybe something like:
|
|
||||||
|
|
||||||
export const negate = {'T:number': ({T}) => R_(T, n => -n)}
|
|
||||||
|
|
||||||
where 'T:number' is a new notation that means "T is a (non-strict) subtype
|
|
||||||
of number". That doesn't look too bad, but if we went down that route, we
|
|
||||||
might also need to make sure that if we ever implemented a PositiveNumber
|
|
||||||
type and a NegativeNumber type then we could say
|
|
||||||
|
|
||||||
export const negate = {
|
|
||||||
PositiveNumber: () => R_('NegativeNumber', n => -n),
|
|
||||||
NegativeNumber: () => R_('PositiveNumber', n => -n),
|
|
||||||
'T:number': ({T}) => R_(T, n => -n)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
This just gets worse if there are also PosNumInt and NegNumInt types;
|
|
||||||
maybe Positive<T>, Negative<T>, and Zero<T> are template types, so that
|
|
||||||
we could say:
|
|
||||||
|
|
||||||
export const negate = {
|
|
||||||
'Positive<T:number>': ({'Negative<T>': negT}) => R_(negT, n => -n),
|
|
||||||
'Negative<T:number>': ({'Positive<T>': posT}) => R_(posT, n => -n),
|
|
||||||
T:number: ({T}) => R_(T, n => -n)
|
|
||||||
}
|
|
||||||
|
|
||||||
But all of that is pretty speculative, for now let's just go with writing
|
|
||||||
out the different types.
|
|
||||||
*/
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ describe('The default full pocomath instance "math"', () => {
|
||||||
|
|
||||||
it('can subtract numbers', () => {
|
it('can subtract numbers', () => {
|
||||||
assert.strictEqual(math.subtract(12, 5), 7)
|
assert.strictEqual(math.subtract(12, 5), 7)
|
||||||
//assert.strictEqual(math.subtract(3n, 1.5), 1.5)
|
assert.throws(() => math.subtract(3n, 1.5), 'TypeError')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can add numbers', () => {
|
it('can add numbers', () => {
|
||||||
|
|
Loading…
Add table
Reference in a new issue