@embracesql/shared
Advanced tools
Comparing version 0.0.4 to 0.0.6
{ | ||
"name": "@embracesql/shared", | ||
"version": "0.0.4", | ||
"version": "0.0.6", | ||
"description": "EmbraceSQL shared types between browser and node.", | ||
@@ -17,3 +17,3 @@ "type": "module", | ||
}, | ||
"gitHead": "343e5761ee3ff99619a2ba34c18139d0ef6240ad" | ||
"gitHead": "924be0edd5fb5b1941d1c9160198ddfda5b93b47" | ||
} |
828
src/ast.ts
@@ -1,6 +0,34 @@ | ||
import { GeneratesTypeScriptParser, GenerationContext } from "."; | ||
import { | ||
GeneratesTypeScript, | ||
GenerationContext, | ||
cleanIdentifierForTypescript, | ||
} from "."; | ||
import { DispatchOperation } from "./index"; | ||
import { camelCase, pascalCase } from "change-case"; | ||
import * as fs from "fs"; | ||
import * as path from "path"; | ||
/** | ||
* Common name for return results. | ||
* | ||
* When you are looking for results... look here 🤪. | ||
*/ | ||
export const RESULTS = "results"; | ||
/** | ||
* Common name for passed in parameters used to filter and search. | ||
* | ||
* Think -- things that go in a WHERE clause. | ||
*/ | ||
export const PARAMETERS = "parameters"; | ||
/** | ||
* Common name for passed in values to set or read. | ||
* | ||
* Think -- thinks that go in a SELECT <columns> clause | ||
* or in the SET <column>=<value> clause. | ||
*/ | ||
export const VALUES = "values"; | ||
/** | ||
* Enumeration tags for quick type discrimination via `switch`. | ||
@@ -11,3 +39,3 @@ * | ||
*/ | ||
export const enum ASTKind { | ||
export enum ASTKind { | ||
Node, | ||
@@ -22,29 +50,39 @@ Database, | ||
Types, | ||
CreateOperation, | ||
ReadOperation, | ||
UpdateOperation, | ||
DeleteOperation, | ||
Scripts, | ||
ScriptFolder, | ||
Script, | ||
Procedures, | ||
Procedure, | ||
ProcedureArgument, | ||
Type, | ||
Enum, | ||
CompositeType, | ||
AliasType, | ||
Attribute, | ||
DomainType, | ||
ArrayType, | ||
} | ||
/** | ||
* Nameable items, which is going to be nearly everything | ||
* in the database. | ||
*/ | ||
export interface IsNamed { | ||
name: string; | ||
typescriptName: string; | ||
interface DatabaseNamed { | ||
/** | ||
* Fully qualified name as expected to exist in the database. | ||
* | ||
* Given that we are connecting to a single database, the names | ||
* are of the form <schema>.<table> | ||
*/ | ||
databaseName: string; | ||
} | ||
export function isNamed(node: ASTNode | IsNamed): node is IsNamed { | ||
return (node as IsNamed).name !== undefined; | ||
} | ||
/** | ||
* Some nodes are containers. | ||
* A named type -- it's column like. | ||
*/ | ||
export interface IsContainer extends IsNamed { | ||
children: ASTNode[]; | ||
export interface NamedType { | ||
name: string; | ||
type: TypeNode; | ||
} | ||
export function isContainer(node: ASTNode | IsContainer): node is IsContainer { | ||
return (node as IsContainer).children !== undefined; | ||
} | ||
/** | ||
@@ -67,2 +105,3 @@ * Use this to visit and generate code. | ||
before?: VisitationHandler<T>; | ||
during?: VisitationHandler<T>; | ||
after?: VisitationHandler<T>; | ||
@@ -72,15 +111,48 @@ } | ||
/** | ||
* And a big old map of visitors for each node type. | ||
* And a big old map kinds to types. | ||
*/ | ||
export type ASTKindMap = { | ||
[ASTKind.Node]: ASTNode; | ||
[ASTKind.Database]: DatabaseNode; | ||
[ASTKind.Schema]: SchemaNode; | ||
[ASTKind.Table]: TableNode; | ||
[ASTKind.Tables]: TableNode; | ||
[ASTKind.Column]: ColumnNode; | ||
[ASTKind.Index]: IndexNode; | ||
[ASTKind.IndexColumn]: IndexColumnNode; | ||
[ASTKind.Types]: TypesNode; | ||
[ASTKind.CreateOperation]: CreateOperationNode; | ||
[ASTKind.ReadOperation]: ReadOperationNode; | ||
[ASTKind.UpdateOperation]: UpdateOperationNode; | ||
[ASTKind.DeleteOperation]: DeleteOperationNode; | ||
[ASTKind.Scripts]: ScriptsNode; | ||
[ASTKind.ScriptFolder]: ScriptFolderNode; | ||
[ASTKind.Script]: ScriptNode; | ||
[ASTKind.Procedures]: ProceduresNode; | ||
[ASTKind.Procedure]: ProcedureNode; | ||
[ASTKind.ProcedureArgument]: ProcedureArgumentNode; | ||
[ASTKind.Type]: TypeNode; | ||
[ASTKind.Enum]: EnumTypeNode; | ||
[ASTKind.CompositeType]: CompositeTypeNode; | ||
[ASTKind.Attribute]: AttributeNode; | ||
[ASTKind.AliasType]: AliasTypeNode; | ||
[ASTKind.DomainType]: DomainTypeNode; | ||
[ASTKind.ArrayType]: ArrayTypeNode; | ||
}; | ||
/** | ||
* Node type predicate. | ||
*/ | ||
export function isNodeType<T extends ASTKind>( | ||
node: ASTNode | undefined, | ||
kind: T, | ||
): node is ASTKindMap[T] { | ||
return node?.kind === kind; | ||
} | ||
/** | ||
* Mapping to set up visitors. | ||
*/ | ||
export type VisitorMap = { | ||
[ASTKind.Node]?: Visitor<ASTNode>; | ||
[ASTKind.Database]?: Visitor<DatabaseNode>; | ||
[ASTKind.Schema]?: Visitor<SchemaNode>; | ||
[ASTKind.Table]?: Visitor<TableNode>; | ||
[ASTKind.Tables]?: Visitor<TableNode>; | ||
[ASTKind.Column]?: Visitor<ColumnNode>; | ||
[ASTKind.Index]?: Visitor<IndexNode>; | ||
[ASTKind.IndexColumn]?: Visitor<IndexColumnNode>; | ||
[ASTKind.Type]?: Visitor<TypeNode>; | ||
[ASTKind.Types]?: Visitor<TypeNode>; | ||
[Kind in keyof ASTKindMap]?: Visitor<ASTKindMap[Kind]>; | ||
}; | ||
@@ -97,5 +169,25 @@ | ||
public kind: ASTKind, | ||
public parent?: NamedASTNode, | ||
) {} | ||
public parent?: ContainerNode, | ||
) { | ||
ASTNode._runningObjectTable.push(this); | ||
parent?.add(this); | ||
} | ||
// track all ast nodes created | ||
static _runningObjectTable: ASTNode[] = []; | ||
static verify() { | ||
// nodes with parents need parents to know this child | ||
for (const node of ASTNode._runningObjectTable) { | ||
if (node.parent) { | ||
console.assert( | ||
node.parent.includes(node), | ||
`${ASTKind[node.kind]} ${(node as NamedASTNode).name} not in ${ | ||
node.parent.typescriptNamespacedName | ||
}`, | ||
); | ||
} | ||
} | ||
} | ||
async visit<T extends this>(context: GenerationContext): Promise<string> { | ||
@@ -115,4 +207,5 @@ const generationBuffer = [""]; | ||
dispatchName(operation: DispatchOperation = ""): string { | ||
return operation; | ||
lookUpTo<T extends ASTKind>(kind: T): ASTKindMap[T] | undefined { | ||
if (isNodeType(this.parent, kind)) return this.parent; | ||
else return this.parent?.lookUpTo(kind); | ||
} | ||
@@ -125,7 +218,7 @@ } | ||
*/ | ||
export abstract class NamedASTNode extends ASTNode implements IsNamed { | ||
export abstract class NamedASTNode extends ASTNode { | ||
constructor( | ||
public name: string, | ||
kind: ASTKind, | ||
parent?: NamedASTNode, | ||
parent?: ContainerNode, | ||
) { | ||
@@ -146,2 +239,14 @@ super(kind, parent); | ||
} | ||
get typescriptPropertyName() { | ||
return camelCase(cleanIdentifierForTypescript(this.name)); | ||
} | ||
get typescriptNamespacedPropertyName(): string { | ||
if (this.parent) { | ||
return `${this.parent.typescriptNamespacedName}.${this.typescriptPropertyName}`; | ||
} else { | ||
return `${this.typescriptPropertyName}`; | ||
} | ||
} | ||
} | ||
@@ -152,6 +257,3 @@ | ||
*/ | ||
export abstract class ContainerNode | ||
extends NamedASTNode | ||
implements IsContainer | ||
{ | ||
export abstract class ContainerNode extends NamedASTNode { | ||
children: ASTNode[] = []; | ||
@@ -162,2 +264,10 @@ constructor(name: string, kind: ASTKind, parent?: ContainerNode) { | ||
add(child: ASTNode) { | ||
this.children.push(child); | ||
} | ||
includes(child: ASTNode) { | ||
return this.children.includes(child); | ||
} | ||
async visit<T extends this>(context: GenerationContext): Promise<string> { | ||
@@ -169,2 +279,5 @@ const generationBuffer = [""]; | ||
); | ||
generationBuffer.push( | ||
visitor?.during ? await visitor?.during(context, this as T) : "", | ||
); | ||
@@ -190,2 +303,3 @@ // and here is that recursion | ||
private types = new Map<string | number, TypeNode>(); | ||
private tables = new Map<string | number, TableNode>(); | ||
@@ -196,9 +310,29 @@ constructor(public name: string) { | ||
registerType(id: string | number, type: TypeNode) { | ||
registerType(id: string | number, type: AbstractTypeNode) { | ||
const existing = this.types.get(`${id}`); | ||
if (existing) return existing; | ||
this.types.set(`${id}`, type); | ||
return type; | ||
} | ||
resolveType(id: string | number) { | ||
return this.types.get(`${id}`); | ||
resolveType<T extends AbstractTypeNode>(id: string | number) { | ||
return this.types.get(`${id}`) as T; | ||
} | ||
registerTable(id: string | number, table: TableNode) { | ||
this.tables.set(`${id}`, table); | ||
} | ||
resolveTable(id: string | number) { | ||
return this.tables.get(`${id}`); | ||
} | ||
resolveSchema(name: string) { | ||
const exists = this.children.find( | ||
(c) => (c as unknown as NamedASTNode)?.name === name, | ||
) as SchemaNode; | ||
if (exists) return exists; | ||
const schema = new SchemaNode(this, name); | ||
return schema; | ||
} | ||
} | ||
@@ -218,4 +352,16 @@ | ||
super(name, ASTKind.Schema, database); | ||
new TypesNode(this); | ||
new ProceduresNode(this); | ||
} | ||
get types() { | ||
return this.children.find((c) => c.kind === ASTKind.Types) as TypesNode; | ||
} | ||
get procedures() { | ||
return this.children.find( | ||
(c) => c.kind === ASTKind.Procedures, | ||
) as ProceduresNode; | ||
} | ||
async visit(context: GenerationContext): Promise<string> { | ||
@@ -243,3 +389,3 @@ if (context?.skipSchemas?.includes(this.name)) { | ||
export class TypesNode extends ContainerNode { | ||
constructor(schema: SchemaNode) { | ||
constructor(public schema: SchemaNode) { | ||
super("Types", ASTKind.Types, schema); | ||
@@ -250,14 +396,42 @@ } | ||
/** | ||
* Represents a single type inside of postgres. | ||
* Shared base for type nodes. | ||
* | ||
* These are grouped by schema. | ||
* These differ on their enumerated type kind | ||
*/ | ||
export class TypeNode extends NamedASTNode { | ||
export class AbstractTypeNode extends ContainerNode { | ||
constructor( | ||
name: string, | ||
kind: ASTKind, | ||
parent: ContainerNode, | ||
public id: string | number, | ||
private parser?: GeneratesTypeScript, | ||
) { | ||
super(name, kind, parent); | ||
} | ||
typescriptTypeParser(context: GenerationContext) { | ||
return this.parser?.typescriptTypeParser(context); | ||
} | ||
typescriptNullOrUndefined(context: GenerationContext) { | ||
console.assert(context); | ||
return `if (from === null || from === undefined) return null;`; | ||
} | ||
typescriptTypeDefinition(context: GenerationContext) { | ||
return this.parser?.typescriptTypeDefinition(context); | ||
} | ||
} | ||
/** | ||
* Represents a single type from a database. | ||
*/ | ||
export class TypeNode extends AbstractTypeNode { | ||
constructor( | ||
name: string, | ||
types: TypesNode, | ||
public id: string | number, | ||
public parser: GeneratesTypeScriptParser, | ||
parser: GeneratesTypeScript, | ||
) { | ||
super(name, ASTKind.Type, types); | ||
super(name, ASTKind.Type, types, id, parser); | ||
} | ||
@@ -267,12 +441,72 @@ } | ||
/** | ||
* Represents an array type from a database. | ||
*/ | ||
export class ArrayTypeNode extends AbstractTypeNode { | ||
public memberType?: AbstractTypeNode; | ||
constructor( | ||
name: string, | ||
types: TypesNode, | ||
public id: string | number, | ||
) { | ||
super(name, ASTKind.ArrayType, types, id); | ||
} | ||
typescriptTypeDefinition(context: GenerationContext) { | ||
console.assert(context); | ||
return ` | ||
Array<${this.memberType?.typescriptNamespacedName ?? "void"}> | ||
`; | ||
} | ||
typescriptTypeParser(context: GenerationContext) { | ||
console.assert(context); | ||
if (this.memberType) { | ||
return ` | ||
const rawArray = Array.isArray(from) ? from : JSON.parse(from as string) as unknown[]; | ||
return rawArray.map((e:unknown) => ${this.memberType.typescriptName}.parse(e)); | ||
`; | ||
} else { | ||
throw new Error(`${this.memberType} could not resolve type of element`); | ||
} | ||
} | ||
typescriptNullOrUndefined(context: GenerationContext) { | ||
console.assert(context); | ||
return `if (from === null || from === undefined) return [];`; | ||
} | ||
} | ||
/** | ||
* Represents a single enum from a database. | ||
*/ | ||
export class EnumTypeNode extends AbstractTypeNode { | ||
constructor( | ||
name: string, | ||
public values: string[], | ||
types: TypesNode, | ||
public id: string | number, | ||
parser: GeneratesTypeScript, | ||
) { | ||
super(name, ASTKind.Enum, types, id, parser); | ||
} | ||
override typescriptTypeParser(context: GenerationContext) { | ||
console.assert(context); | ||
return [ | ||
` if(Object.values(${this.typescriptNamespacedName}).includes(from as ${this.typescriptNamespacedName})) {`, | ||
` return from as ${this.typescriptNamespacedName};`, | ||
` } else {`, | ||
` return undefined;`, | ||
` }`, | ||
].join("\n"); | ||
} | ||
} | ||
/** | ||
* Collects all tables in a schema in a database. | ||
*/ | ||
export class TablesNode extends ContainerNode { | ||
constructor(schema: SchemaNode) { | ||
constructor(public schema: SchemaNode) { | ||
super("Tables", ASTKind.Tables, schema); | ||
} | ||
dispatchName(operation: DispatchOperation = "") { | ||
return this.parent?.dispatchName(operation) ?? ""; | ||
} | ||
} | ||
@@ -286,19 +520,51 @@ | ||
*/ | ||
export class TableNode extends ContainerNode { | ||
export class TableNode extends ContainerNode implements DatabaseNamed { | ||
constructor( | ||
tables: TablesNode, | ||
public tables: TablesNode, | ||
public name: string, | ||
public type: CompositeTypeNode, | ||
) { | ||
super(name, ASTKind.Table, tables); | ||
new CreateOperationNode(this); | ||
} | ||
dispatchName(operation: DispatchOperation = "") { | ||
return `${this.parent?.dispatchName()}.${pascalCase( | ||
this.name, | ||
)}${operation}`; | ||
get databaseName() { | ||
return `${this.tables.schema.name}.${this.name}`; | ||
} | ||
get createOperation() { | ||
return this.children.find( | ||
(c) => c.kind === ASTKind.CreateOperation, | ||
) as CreateOperationNode; | ||
} | ||
get primaryKey(): IndexNode | undefined { | ||
return this.children.find((n) => (n as IndexNode).primaryKey) as IndexNode; | ||
} | ||
get columnsInPrimaryKey(): ColumnNode[] { | ||
const primaryKeyNames = this.primaryKey | ||
? this.primaryKey.columns.map((c) => c.name) | ||
: []; | ||
return this.allColumns.filter((a) => primaryKeyNames.includes(a.name)); | ||
} | ||
get columnsNotInPrimaryKey(): ColumnNode[] { | ||
const primaryKeyNames = this.primaryKey | ||
? this.primaryKey.columns.map((c) => c.name) | ||
: []; | ||
return this.allColumns.filter((a) => !primaryKeyNames.includes(a.name)); | ||
} | ||
get optionalColumns(): ColumnNode[] { | ||
return this.children | ||
.filter<ColumnNode>((n): n is ColumnNode => isNodeType(n, ASTKind.Column)) | ||
.filter((c) => c.hasDefault); | ||
} | ||
get allColumns(): ColumnNode[] { | ||
return this.children.filter<ColumnNode>((n): n is ColumnNode => | ||
isNodeType(n, ASTKind.Column), | ||
); | ||
} | ||
} | ||
@@ -314,2 +580,4 @@ | ||
public type: TypeNode, | ||
public hasDefault: boolean, | ||
public allowsNull: boolean, | ||
) { | ||
@@ -337,12 +605,21 @@ super(name, ASTKind.Column, table); | ||
constructor( | ||
table: TableNode, | ||
public table: TableNode, | ||
public name: string, | ||
public unique: boolean, | ||
public primaryKey: boolean, | ||
attributes: NamedType[], | ||
) { | ||
super(name, ASTKind.Index, table); | ||
attributes.forEach((a) => new IndexColumnNode(this, a.name, a.type)); | ||
// important that the operations go after the attributes | ||
// so that we can have a well defined `typescriptName` | ||
new ReadOperationNode(this); | ||
new UpdateOperationNode(this); | ||
new DeleteOperationNode(this); | ||
} | ||
dispatchName(operation: DispatchOperation = "") { | ||
return `${this.parent?.dispatchName()}.${camelCase(this.name)}${operation}`; | ||
get typescriptName() { | ||
return `By${pascalCase( | ||
this.columns.map((c) => c.typescriptName).join("_"), | ||
)}`; | ||
} | ||
@@ -360,10 +637,427 @@ | ||
*/ | ||
export class IndexColumnNode extends ContainerNode { | ||
export class IndexColumnNode extends ContainerNode implements NamedType { | ||
constructor( | ||
table: IndexNode, | ||
public index: IndexNode, | ||
public name: string, | ||
public type: TypeNode, | ||
) { | ||
super(name, ASTKind.IndexColumn, table); | ||
super(name, ASTKind.IndexColumn, index); | ||
} | ||
} | ||
// operations | ||
export abstract class OperationNode extends ContainerNode { | ||
get parametersType() { | ||
return this.children | ||
.filter<CompositeTypeNode>( | ||
(c): c is CompositeTypeNode => c.kind === ASTKind.CompositeType, | ||
) | ||
.find((c) => c.name === PARAMETERS); | ||
} | ||
} | ||
/** | ||
* Function like operations -- scripts and procedures. | ||
*/ | ||
export abstract class FunctionOperationNode extends OperationNode { | ||
constructor( | ||
name: string, | ||
kind: ASTKind, | ||
parent: ContainerNode, | ||
public returnsMany: boolean, | ||
) { | ||
// always returnsMany | ||
super(name, kind, parent); | ||
} | ||
/** | ||
* Operations have results, which are each of this type. | ||
* | ||
* An operation can return either a single value or arrary of this type. | ||
*/ | ||
get resultsType() { | ||
return this.children | ||
.filter<AbstractTypeNode>((c): c is AbstractTypeNode => | ||
[ASTKind.CompositeType, ASTKind.AliasType].includes(c.kind), | ||
) | ||
.find((c) => c.name === RESULTS); | ||
} | ||
/** | ||
* The results type might be an alias -- resolve it to the target | ||
* final type. | ||
*/ | ||
get resultsResolvedType() { | ||
const typeNode = this.resultsType; | ||
// resolve type alias to a composite | ||
return typeNode?.kind === ASTKind.AliasType | ||
? (typeNode as AliasTypeNode).type | ||
: typeNode; | ||
} | ||
} | ||
/** | ||
* Operation to create a new row in a table. Each table gets one. | ||
*/ | ||
export class CreateOperationNode extends OperationNode { | ||
constructor(public table: TableNode) { | ||
super("create", ASTKind.CreateOperation, table); | ||
} | ||
} | ||
/** | ||
* Shared base class for index operations. | ||
* | ||
* This establishes a naming protocol per index. | ||
*/ | ||
export abstract class IndexOperationNode extends OperationNode { | ||
constructor( | ||
name: string, | ||
kind: ASTKind, | ||
public index: IndexNode, | ||
) { | ||
super(name, kind, index); | ||
} | ||
} | ||
/** | ||
* Operation to read row(s) by index. | ||
*/ | ||
export class ReadOperationNode extends IndexOperationNode { | ||
constructor(public index: IndexNode) { | ||
super("read", ASTKind.ReadOperation, index); | ||
} | ||
} | ||
/** | ||
* Operation to delete row(s) by index. | ||
*/ | ||
export class DeleteOperationNode extends IndexOperationNode { | ||
constructor(public index: IndexNode) { | ||
super("delete", ASTKind.DeleteOperation, index); | ||
} | ||
} | ||
/** | ||
* Update row(s) by index. | ||
*/ | ||
export class UpdateOperationNode extends IndexOperationNode { | ||
constructor(public index: IndexNode) { | ||
super("update", ASTKind.UpdateOperation, index); | ||
} | ||
} | ||
/** | ||
* Collects scripts into a hierarchy sourced from a | ||
* folder tree on disk. | ||
*/ | ||
export class ScriptsNode extends ContainerNode { | ||
static SCRIPTS = "Scripts"; | ||
/** | ||
* Loading up the scripts node by file system traversal. | ||
* | ||
* Once done, all scripts will be visited and loaded into the AST. | ||
*/ | ||
static async loadAST(context: GenerationContext) { | ||
if (context.sqlScriptsFrom) { | ||
const rootPath = path.parse(path.join(context.sqlScriptsFrom)); | ||
const scriptsNode = new ScriptsNode(context.database, rootPath); | ||
await ScriptFolderNode.loadAST(context, rootPath, scriptsNode); | ||
return scriptsNode; | ||
} else { | ||
return undefined; | ||
} | ||
} | ||
constructor( | ||
public database: DatabaseNode, | ||
public path: path.ParsedPath, | ||
) { | ||
super("Scripts", ASTKind.Scripts, database); | ||
} | ||
get typescriptNamespacedName() { | ||
// not returning the database above, scripts serves as a backstop | ||
return this.typescriptName; | ||
} | ||
} | ||
/** | ||
* A single folder of scripts on disk. | ||
*/ | ||
export class ScriptFolderNode extends ContainerNode { | ||
/** | ||
* Asynchronous factory builds from a folder path on disk. | ||
*/ | ||
static async loadAST( | ||
context: GenerationContext, | ||
searchPath: path.ParsedPath, | ||
addToNode: ContainerNode, | ||
) { | ||
// reading the whole directory | ||
const inPath = await fs.promises.readdir( | ||
path.join(searchPath.dir, searchPath.base), | ||
{ | ||
withFileTypes: true, | ||
}, | ||
); | ||
for (const entry of inPath) { | ||
if (entry.isDirectory()) { | ||
const folder = new ScriptFolderNode( | ||
path.parse(path.join(entry.path, entry.name)), | ||
addToNode, | ||
); | ||
await ScriptFolderNode.loadAST(context, folder.path, folder); | ||
} else if (entry.name.endsWith(".sql")) { | ||
await ScriptNode.loadAST( | ||
context, | ||
path.parse(path.join(entry.path, entry.name)), | ||
addToNode, | ||
); | ||
} | ||
} | ||
} | ||
constructor( | ||
public path: path.ParsedPath, | ||
parent: ContainerNode, | ||
) { | ||
super(path.name, ASTKind.ScriptFolder, parent); | ||
} | ||
} | ||
/** | ||
* A single script that is source from a .sql file on disk. | ||
*/ | ||
export class ScriptNode extends FunctionOperationNode { | ||
/** | ||
* Asynchronous factory builds from a sql file on disk. | ||
*/ | ||
static async loadAST( | ||
context: GenerationContext, | ||
scriptPath: path.ParsedPath, | ||
addToNode: ContainerNode, | ||
) { | ||
console.assert(context); | ||
new ScriptNode( | ||
scriptPath, | ||
await fs.promises.readFile(path.join(scriptPath.dir, scriptPath.base), { | ||
encoding: "utf8", | ||
}), | ||
addToNode, | ||
); | ||
} | ||
constructor( | ||
public path: path.ParsedPath, | ||
public script: string, | ||
parent: ContainerNode, | ||
) { | ||
// always returnsMany | ||
super(path.name, ASTKind.Script, parent, true); | ||
} | ||
} | ||
/** | ||
* Collects all procedures in a schema in a database. | ||
*/ | ||
export class ProceduresNode extends ContainerNode { | ||
constructor(public schema: SchemaNode) { | ||
super("Procedures", ASTKind.Procedures, schema); | ||
} | ||
} | ||
/** | ||
* A single stored procedure or function. | ||
*/ | ||
export class ProcedureNode | ||
extends FunctionOperationNode | ||
implements DatabaseNamed | ||
{ | ||
constructor( | ||
name: string, | ||
public procedures: ProceduresNode, | ||
public id: string | number, | ||
public nameInDatabase: string, | ||
returnsMany: boolean, | ||
public isPseudoType: boolean, | ||
) { | ||
super(name, ASTKind.Procedure, procedures, returnsMany); | ||
} | ||
get databaseName() { | ||
return `${this.procedures.schema.name}.${this.nameInDatabase}`; | ||
} | ||
} | ||
/** | ||
* A single argument to a procedure. This is a 'named type' | ||
*/ | ||
export class ProcedureArgumentNode extends NamedASTNode { | ||
constructor( | ||
name: string, | ||
public procedure: ProcedureNode, | ||
public type: TypeNode, | ||
public hasDefault: boolean, | ||
) { | ||
super(name, ASTKind.ProcedureArgument, procedure); | ||
} | ||
} | ||
/** | ||
* A composite type is built of named attributes, each with their own type. | ||
*/ | ||
export class CompositeTypeNode extends AbstractTypeNode { | ||
constructor(name: string, parent: ContainerNode, id: string | number) { | ||
super(name, ASTKind.CompositeType, parent, id); | ||
} | ||
get attributes() { | ||
return this.children.filter<AttributeNode>( | ||
(c): c is AttributeNode => c.kind === ASTKind.Attribute, | ||
); | ||
} | ||
override typescriptTypeDefinition( | ||
context: GenerationContext, | ||
): string | undefined { | ||
console.assert(context); | ||
const recordAttributes = this.children | ||
.filter<AttributeNode>( | ||
(c): c is AttributeNode => c.kind === ASTKind.Attribute, | ||
) | ||
.map((a) => { | ||
if (a.type.kind === ASTKind.ArrayType) { | ||
return `${a.typescriptPropertyName}: ${a.type.typescriptNamespacedName};`; | ||
} | ||
if (a.nullable) { | ||
return `${a.typescriptPropertyName}: Nullable<${a.type.typescriptNamespacedName}>;`; | ||
} | ||
return `${a.typescriptPropertyName}: ${a.type.typescriptNamespacedName};`; | ||
}); | ||
return ` { ${recordAttributes.join("\n")} } `; | ||
} | ||
override typescriptTypeParser(context: GenerationContext) { | ||
console.assert(context); | ||
// parsing on the client side needs to turn 'loose' json types | ||
// into our database mapped types -- for example -- dates | ||
// parsing on the server side -- the database driver is expected to have | ||
// already done the parsing, so this is a no-op | ||
return [ | ||
`if (${this.typescriptNamespacedName}.is(from)) {`, | ||
` return {`, | ||
this.children | ||
.filter<AttributeNode>( | ||
(c): c is AttributeNode => c.kind === ASTKind.Attribute, | ||
) | ||
.map( | ||
(a) => | ||
`${a.typescriptPropertyName}: ${a.type.typescriptNamespacedName}.parse(from.${a.typescriptPropertyName}),`, | ||
) | ||
.join("\n"), | ||
`};`, | ||
`}`, | ||
`throw new Error(JSON.stringify(from))`, | ||
].join("\n"); | ||
} | ||
} | ||
/** | ||
* A single named type. | ||
*/ | ||
export class AttributeNode extends ContainerNode implements NamedType { | ||
constructor( | ||
public parent: CompositeTypeNode, | ||
public name: string, | ||
public index: number, | ||
public type: TypeNode, | ||
public required: boolean, | ||
public nullable: boolean, | ||
) { | ||
super(name, ASTKind.Attribute, parent); | ||
} | ||
/** | ||
* Generate a synthetic name based on the index | ||
* when no name is provided. | ||
*/ | ||
get typescriptPropertyName(): string { | ||
if (this.name) return super.typescriptPropertyName; | ||
else return `argument_${this.index}`; | ||
} | ||
} | ||
/** | ||
* Rename a type, useful in namespaces to allow consistent | ||
* code generation. | ||
*/ | ||
export class AliasTypeNode extends AbstractTypeNode { | ||
constructor( | ||
public name: string, | ||
public type: TypeNode, | ||
parent: ContainerNode, | ||
) { | ||
super(name, ASTKind.AliasType, parent, type.id); | ||
} | ||
override typescriptTypeDefinition( | ||
context: GenerationContext, | ||
): string | undefined { | ||
console.assert(context); | ||
if (this.name === RESULTS) { | ||
if (this.type.kind === ASTKind.CompositeType) { | ||
return `NullableMembers<${this.type.typescriptNamespacedName}>`; | ||
} | ||
return `Nullable<${this.type.typescriptNamespacedName}>`; | ||
} | ||
return `${this.type.typescriptNamespacedName}`; | ||
} | ||
override typescriptTypeParser(context: GenerationContext) { | ||
console.assert(context); | ||
// delegate to the actual type | ||
return `return ${this.type.typescriptNamespacedName}.parse(from)`; | ||
} | ||
} | ||
/** | ||
* A domain type is much like an alias, in that it gives a new name | ||
* to an existing type. | ||
* | ||
* The difference is that the database will narrow the range of acceptable | ||
* values for a domain. | ||
* | ||
* For example, imagine a ... useless admittedly ... year type: | ||
* | ||
* ```sql | ||
* CREATE DOMAIN year AS integer | ||
* CONSTRAINT year_check CHECK (((VALUE >= 1901) AND (VALUE <= 2155))); | ||
* ``` | ||
* | ||
*/ | ||
export class DomainTypeNode extends AbstractTypeNode { | ||
private _baseType?: AbstractTypeNode; | ||
constructor(name: string, parent: ContainerNode, id: string | number) { | ||
super(name, ASTKind.DomainType, parent, id); | ||
} | ||
set baseType(baseType: AbstractTypeNode | undefined) { | ||
this._baseType = baseType; | ||
} | ||
get baseType() { | ||
return this._baseType; | ||
} | ||
override typescriptTypeDefinition(context: GenerationContext) { | ||
console.assert(context); | ||
// just alias the base type | ||
return `${this.baseType?.typescriptNamespacedName}`; | ||
} | ||
override typescriptTypeParser(context: GenerationContext) { | ||
console.assert(context); | ||
// base type type parser | ||
return `return ${this.baseType?.typescriptNamespacedName}.parse(from);`; | ||
} | ||
} |
@@ -6,2 +6,3 @@ import { DatabaseNode, VisitorMap } from "./ast"; | ||
export * from "./parsers"; | ||
export * from "./debounce"; | ||
@@ -38,8 +39,11 @@ /** | ||
export type GenerationContextProps = { | ||
skipSchemas?: string[]; | ||
sqlScriptsFrom?: string; | ||
}; | ||
/** | ||
* Shared context for the generation sequence. | ||
*/ | ||
export type GenerationContext = { | ||
sqlScriptsFrom?: string; | ||
skipSchemas?: string[]; | ||
export type GenerationContext = GenerationContextProps & { | ||
database: DatabaseNode; | ||
@@ -77,8 +81,17 @@ handlers?: VisitorMap; | ||
*/ | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export const undefinedIsNull = (value: any) => { | ||
export function undefinedIsNull<T>(value: T | undefined) { | ||
if (value === undefined) return null; | ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return | ||
return value; | ||
}; | ||
} | ||
/** | ||
* Adapter when we get a parsed null from the database but should | ||
* return an undefined for TypeScript | ||
* undefined to null. | ||
*/ | ||
export function nullIsUndefined<T>(value: T | null) { | ||
if (value === null) return undefined; | ||
return value; | ||
} | ||
export type JsDate = Date; | ||
@@ -95,4 +108,8 @@ export type Empty = Record<string, never>; | ||
}; | ||
export type Nullable<T> = T | null | undefined; | ||
export type Nullable<T> = T | null; | ||
export type NullableMembers<T> = { | ||
[Member in keyof T]: Nullable<T[Member]>; | ||
}; | ||
export * from "./uuid"; | ||
@@ -105,2 +122,25 @@ | ||
export type { GeneratesTypeScriptParser } from "./typescript"; | ||
export type { GeneratesTypeScript as GeneratesTypeScriptParser } from "./typescript"; | ||
// identifier legalizer? | ||
const notLegalInIdentifiers = /[^\w$]/g; | ||
/** | ||
* Clean identifiers to be only legal characters. | ||
*/ | ||
export const cleanIdentifierForTypescript = (identifier: string) => { | ||
return identifier.replace(notLegalInIdentifiers, "_"); | ||
}; | ||
type CommonKeys<S, T> = { | ||
[K in keyof S & keyof T]: [S[K], T[K]] extends [T[K], S[K]] ? K : never; | ||
}[keyof S & keyof T]; | ||
/** | ||
* Make just SOME properties optional. | ||
*/ | ||
export type PartiallyOptional<T, U> = | ||
// keys in common -- these will be optional | ||
Partial<Pick<T, CommonKeys<T, U>>> & | ||
// keys not in common, these will remain as is from T | ||
Omit<T, CommonKeys<T, U>>; |
@@ -11,3 +11,3 @@ import { delimiter } from "."; | ||
const separators = parsimmon.oneOf("{},"); | ||
const arraySeparators = parsimmon.oneOf("{},"); | ||
@@ -54,3 +54,3 @@ /** | ||
const quotedString = parsimmon | ||
.alt(neverRequiresEscape, parseEscapedArrayValue, separators) | ||
.alt(neverRequiresEscape, parseEscapedArrayValue, arraySeparators) | ||
.many() | ||
@@ -57,0 +57,0 @@ .wrap(parsimmon.string('"'), parsimmon.string('"')) |
@@ -15,3 +15,3 @@ import { interleave } from "."; | ||
*/ | ||
const SEPARATORS = `(),`; | ||
const SEPARATORS = `{}(),`; | ||
const separators = parsimmon.oneOf(SEPARATORS); | ||
@@ -18,0 +18,0 @@ const startComposite = parsimmon.string("("); |
@@ -1,48 +0,16 @@ | ||
import { GenerationContext } from "."; | ||
import { ASTNode, Visitor, isNamed } from "./ast"; | ||
import { pascalCase } from "change-case"; | ||
import { GenerationContext, NamedASTNode } from "."; | ||
/** | ||
* Give that node a typescript style type name. | ||
*/ | ||
export function typescriptTypeName(node: ASTNode) { | ||
if (isNamed(node)) { | ||
return pascalCase(node.name); | ||
} else { | ||
return ""; | ||
} | ||
} | ||
/** | ||
* Give that node a typescript style type name. | ||
*/ | ||
export function typescriptFullyQualifiedTypeName(node: ASTNode): string { | ||
if (isNamed(node)) { | ||
return node.parent | ||
? `${typescriptFullyQualifiedTypeName(node.parent)}.${pascalCase( | ||
node.name, | ||
)}` | ||
: `${pascalCase(node.name)}`; | ||
} else { | ||
return ""; | ||
} | ||
} | ||
/** | ||
* This is a really simple visitor that names the node into a namespace. | ||
*/ | ||
export const NamespaceVisitor: Visitor<ASTNode> = { | ||
before: async (_, node) => { | ||
if (isNamed(node)) { | ||
return `export namespace ${typescriptTypeName(node)} {`; | ||
} else { | ||
return ""; | ||
} | ||
export const NamespaceVisitor = { | ||
before: async (context: GenerationContext, node: NamedASTNode) => { | ||
console.assert(context); | ||
console.assert(node); | ||
return `export namespace ${node.typescriptName} {`; | ||
}, | ||
after: async (_, node) => { | ||
if (isNamed(node)) { | ||
return "}"; | ||
} else { | ||
return ""; | ||
} | ||
after: async (context: GenerationContext, node: NamedASTNode) => { | ||
console.assert(context); | ||
console.assert(node); | ||
return "}"; | ||
}, | ||
@@ -59,4 +27,11 @@ }; | ||
*/ | ||
export interface GeneratesTypeScriptParser { | ||
export interface GeneratesTypeScript { | ||
/** | ||
* Generate code that parses a string into a typed value. | ||
*/ | ||
typescriptTypeParser(context: GenerationContext): string; | ||
/** | ||
* Generate code that defines the right hand side of a `type =` statement. | ||
*/ | ||
typescriptTypeDefinition(context: GenerationContext): string; | ||
} |
40016
13
1360