ihp icon indicating copy to clipboard operation
ihp copied to clipboard

Typescript Declarations Generator

Open s0kil opened this issue 2 years ago • 1 comments

This module generates Types.d.ts based on Schema.sql. I found it useful working with SPA + DataSync.

TODO:

  • Docs
  • Tests

In your tsconfig.json file: Include the build directory as:

"include": ["./**/*", "../build/Generated/"]

s0kil avatar Mar 16 '22 11:03 s0kil

For reference, here's the TypeScript generator used in IHP Backend:

module Web.View.TypeDefinitions.TypeScript where
import Web.View.Prelude
import IHP.IDE.SchemaDesigner.Types
import NeatInterpolation

generateTypeScriptTypeDefinitions :: Project -> [Statement] -> Text
generateTypeScriptTypeDefinitions project schema = [trimming|
declare module 'ihp-backend' {
    ${tableNameTypeDef}
    ${tableNameToRecordType}

    ${recordInterfaces}
    ${newRecordInterfaces}
    ${newRecordType'}

    type UUID = string;

    class ConditionBuildable<table extends TableName, T extends ConditionBuildable<table, T>> {
        conditionBuildableType: table;

        where<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        where(conditionBuilder: ConditionBuilder<table>): T;
        where(filterRecord: Partial<IHPRecord<table>>): T;
        filterWhere<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        whereNot<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        whereLessThan<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        whereLessThanOrEqual<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        whereGreaterThan<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;
        whereGreaterThanOrEqual<column extends keyof IHPRecord<table>>(column: column, value: IHPRecord<table>[column]): T;

        or (...conditionBuilder: ConditionBuilder<table>[]): T;
        and(...conditionBuilder: ConditionBuilder<table>[]): T;
    }

    class ConditionBuilder<table extends TableName> extends ConditionBuildable<table, ConditionBuilder<table>> {}

    function or <table extends TableName>(   conditions: ConditionBuilder<table>[]): ConditionBuilder<table>;
    function or <table extends TableName>(...conditions: ConditionBuilder<table>[]): ConditionBuilder<table>;
    function and<table extends TableName>(   conditions: ConditionBuilder<table>[]): ConditionBuilder<table>;
    function and<table extends TableName>(...conditions: ConditionBuilder<table>[]): ConditionBuilder<table>;

    class QueryBuilder<table extends TableName, result extends {}> extends ConditionBuildable<table, QueryBuilder<table, result>> {
        table: Query;

        select<column extends keyof IHPRecord<table>>(columns: column[]): QueryBuilder<table, (result extends IHPRecord<table> ? {} : result) & Pick<IHPRecord<table>, column>>;
        select<column extends keyof IHPRecord<table>>(...columns: column[]): QueryBuilder<table, (result extends IHPRecord<table> ? {} : result) & Pick<IHPRecord<table>, column>>;

        whereTextSearchStartsWith<column extends keyof IHPRecord<table>, value extends IHPRecord<table>[column] & string>(column: column, value: value): T;

        orderBy(column: keyof IHPRecord<table>): QueryBuilder<table, result>;
        orderByAsc(column: keyof IHPRecord<table>): QueryBuilder<table, result>;
        orderByDesc(column: keyof IHPRecord<table>): QueryBuilder<table, result>;
        limit(limit: number): QueryBuilder<table, result>;
        offset(limit: number): QueryBuilder<table, result>;

        fetch(): Promise<Array<result>>;
        fetchOne(): Promise<result>;
    }
 
    ${conditionBuilderConstructors}

    interface Query {
        table: TableName;
    }
    
    /**
     * Returns a new database query builder.
     * 
     * @example
     * const tasks = await query('tasks')
     *     .orderBy('createdAt')
     *     .limit(10)
     *     .fetch();
     * 
     * @param {string} table The name of one of your project's table.
     */
    function query<table extends TableName>(table: table): QueryBuilder<table, IHPRecord<table>>;
    function query<table extends TableName, column extends keyof IHPRecord<table>>(table: table, columns: column[]): QueryBuilder<table, Pick<IHPRecord<table>, column>>;

    class DataSubscription {
        isClosed: boolean;
        isConnected: boolean;

        constructor(query: Query);
        createOnServer(): Promise<void>;
        close(): Promise<void>;
        closeIfNotUsed(): Promise<void>;
        getRecords(): Array<object>;
    }

    ${initIHPBackendTypeDef'}

    /**
     * Creates a row inside a database table. Returns a promise of the newly created object.
     * 
     * @example
     * const task = await createRecord('tasks', {
     *     title: 'Hello World',
     *     userId: getCurrentUserId()
     * })
     * @param {string} tableName The name of one of your project's table.
     * @param {object} record An object representing the row to be inserted. Columns with a database-side default value don't need to be specified.
     * @see {@link createRecords} You can use `createRecords` to batch insert multiple records in an efficient way
     */
    function createRecord<table extends TableName>(tableName: table, record: NewRecord<IHPRecord<table>>): Promise<IHPRecord<table>>;

    /**
     * Updates a row inside a database table. Returns a promise of the updated row.
     * 
     * @example
     * updateRecord('tasks', task.id, {
     *     isCompleted: true
     * })
     * @param {string} tableName The name of one of your project's table
     * @param {UUID} id The id of the row to be updated
     * @param {object} patch An patch object representing the changed values.
     */
    function updateRecord<table extends TableName>(tableName: table, id: UUID, patch: Partial<NewRecord<IHPRecord<table>>>): Promise<IHPRecord<table>>;

    /**
     * Updates multiple rows inside a database table. Returns a promise of the updated rows.
     * 
     * @example
     * const taskIds = tasks.map(taks => task.id);
     * updateRecords('tasks', taskIds, {
     *     isCompleted: true
     * })
     * @param {string} tableName The name of one of your project's table
     * @param {Array<UUID>} ids The ids of the rows to be updated
     * @param {object} patch An patch object representing the changed values.
     */
    function updateRecords<table extends TableName>(tableName: table, ids: Array<UUID>, patch: Partial<NewRecord<IHPRecord<table>>>): Promise<Array<IHPRecord<table>>>;

    /**
     * Deletes a row inside a database table.
     * 
     * @example
     * deleteRecord('tasks', task.id)
     * @param {string} tableName The name of one of your project's table
     * @param {UUID} id The id of the row to be deleted
     */
    function deleteRecord<table extends TableName>(tableName: table, id: UUID): Promise<void>;

    /**
     * Deletes multiple rows inside a database table.
     * 
     * @example
     * const taskIds = tasks.map(task => task.id)
     * deleteRecords('tasks', taskIds)
     * @param {string} tableName The name of one of your project's table
     * @param {Array<UUID>} ids The ids of the rows to be deleted
     */
    function deleteRecords<table extends TableName>(tableName: table, ids: Array<UUID>): Promise<void>;

    /**
     * Creates multiple rows inside a database table in a single INSERT query. Returns a promise of the newly created objects.
     * 
     * @example
     * const tasksToCreate = [];
     * 
     * // Make 10 task objects, but don't insert them to the DB yet
     * for (let i = 0; i < 10; i++) {
     *     tasksToCreate.push({
     *         title: `Task $${i}`,
     *         userId: getCurrentUserId()
     *     });
     * }
     * 
     * // Insert the 10 tasks
     * const tasks = await createRecords('tasks', tasksToCreate)
     * @param {string} tableName The name of one of your project's table.
     * @param {object} records An array representing the rows to be inserted.
     */
    function createRecords<table extends TableName>(tableName: table, records: Array<NewRecord<IHPRecord<table>>>): Promise<Array<IHPRecord<table>>>;

    function getCurrentUserId(): string;
    function getCurrentUser(): Promise<User | null>;

    function logout(): Promise<void>;
    
    /**
     * Useful to implement a login button. Redirects the user to the login page.
     *
     * The returned promise never resolves, as the browser is redirected to a different page.
     * 
     * @example
     * import { loginWithRedirect } from 'ihp-backend';
     * function LoginButton() {
     *      const isLoading = useState(false);
     *
     *      const doLogin = async () => {
     *          setLoading(true);
     *          await loginWithRedirect();
     *          setLoading(false);
     *      }
     *
     *      return <button onClick={doLogin} disabled={isLoading}>Login</button>
     * }
     */    
    function loginWithRedirect(): Promise<void>;
    function ensureIsUser(): Promise<void>;
    function initAuth(): Promise<void>;

    class Transaction {
        public transactionId: UUID | null;

        start(): Promise<void>;
        commit(): Promise<void>;
        rollback(): Promise<void>;
    
        query<table extends TableName>(table: table): QueryBuilder<table, IHPRecord<table>>;
        query<table extends TableName, column extends keyof IHPRecord<table>>(table: table, columns: column[]): QueryBuilder<table, Pick<IHPRecord<table>, column>>;
    
        createRecord<table extends TableName>(tableName: table, record: NewRecord<IHPRecord<table>>): Promise<IHPRecord<table>>;
        createRecords<table extends TableName>(tableName: table, records: Array<NewRecord<IHPRecord<table>>>): Promise<Array<IHPRecord<table>>>;
        updateRecord<table extends TableName>(tableName: table, id: UUID, patch: Partial<NewRecord<IHPRecord<table>>>): Promise<IHPRecord<table>>;
        updateRecords<table extends TableName>(tableName: table, ids: Array<UUID>, patch: Partial<NewRecord<IHPRecord<table>>>): Promise<Array<IHPRecord<table>>>;
        deleteRecord<table extends TableName>(tableName: table, id: UUID): Promise<void>;
        deleteRecords<table extends TableName>(tableName: table, ids: Array<UUID>): Promise<void>;
    }

    function withTransaction<returnValue>(callback: ((transaction: Transaction) => Promise<returnValue>) ): Promise<returnValue>;
}

declare module 'ihp-backend/react' {
    import { TableName, QueryBuilder, User } from 'ihp-backend';

    /**
     * React hook for querying the database and streaming results in real-time
     * 
     * @example
     * function TasksList() {
     *      const tasks = useQuery(query('tasks').orderBy('createdAt'))
     *      
     *      return <div>
     *          {tasks.map(task => <div>{task.title}</div>)}
     *      </div>
     * }
     * 
     * @param {QueryBuilder<table, result>} queryBuilder A database query
     */
    function useQuery<table extends TableName, result>(queryBuilder: QueryBuilder<table, result>): Array<result> | null;

    /**
     * A version of `useQuery` when you only want to fetch a single record.
     * 
     * Automatically adds a `.limit(1)` to the query and returns the single result instead of a list.
     * 
     * @example
     * const message = useQuerySingleresult(query('messages').filterWhere('id', '1f290b39-c6d1-4dff-8404-0581f470253c'));
     */
    function useQuerySingleResult<table extends TableName, result>(queryBuilder: QueryBuilder<table, result>): result | null;

    function useCurrentUser(): User | null;
    
    /**
     * Returns true if there's a user logged in. Returns if there's no logged in user. Returns null if loading.
     * 
     * @example
     * const isLoggedIn = useIsLoggedIn();
     */
    function useIsLoggedIn(): boolean | null;

    interface IHPBackendProps {
        requireLogin?: boolean;
        children: JSX.Element[] | JSX.Element;
    }
    function IHPBackend(props: IHPBackendProps): JSX.Element;
}

declare module 'ihp-datasync/react18' {
    import { TableName, QueryBuilder } from 'ihp-backend';

    /**
     * React hook for querying the database and streaming results in real-time.
     *
     * Suspends while the data is being fetched from the server.
     * 
     * @example
     * function TasksList() {
     *      const tasks = useQuery(query('tasks').orderBy('createdAt'))
     *      
     *      return <div>
     *          {tasks.map(task => <div>{task.title}</div>)}
     *      </div>
     * }
     * 
     * @param {QueryBuilder<table, result>} queryBuilder A database query
     */
    function useQuery<table extends TableName, result>(queryBuilder: QueryBuilder<table, result>): Array<result>;
}
|]
    where
        tableNameTypeDef :: Text
        tableNameTypeDef = "type TableName = " <> (tableNames |> map tshow |> intercalate " | " ) <> ";"

        tableNames :: [Text]
        tableNames = createTableStatements |> map (get #name)

        createTableStatements :: [CreateTable]
        createTableStatements = 
                schema |> mapMaybe \case
                    StatementCreateTable { unsafeGetCreateTable = table } -> Just table
                    otherwise -> Nothing

        recordInterfaces :: Text
        recordInterfaces = createTableStatements
                |> map recordInterface
                |> intercalate "\n"
        
        newRecordInterfaces :: Text
        newRecordInterfaces = createTableStatements
                |> map newRecordInterface
                |> intercalate "\n"

        tableNameToRecordType :: Text
        tableNameToRecordType = [trimming|
            type IHPRecord<table> = ${implementation};
        |]
            where
                -- table extends "users" ? UserRecord : (table extends "tasks" ? TaskRecord : never)
                implementation = gen createTableStatements

                gen [] = "never"
                gen (table:rest) = "table extends " <> tshow (get #name table) <> " ? " <> tableNameToModelName (get #name table) <> " : (" <> gen rest <> ")"

        newRecordType' :: Text
        newRecordType' = newRecordType createTableStatements

        initIHPBackendTypeDef' :: Text
        initIHPBackendTypeDef' = initIHPBackendTypeDef project

        conditionBuilderConstructors :: Text
        conditionBuilderConstructors = tableNames
                |> map forTable
                |> intercalate "\n"
            where
                forTable tableName = [trimming|function where(conditionBuilder: ConditionBuilder<'${tableName}'>): ConditionBuilder<'${tableName}'>;
function where(filterRecord: Partial<${record}>)                : ConditionBuilder<'${tableName}'>;
function where             <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function filterWhere       <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function eq                <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function notEq             <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function lessThan          <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function lessThanOrEqual   <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function greaterThan       <column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;
function greaterThanOrEqual<column extends keyof ${record}>(column: column, value: ${record}[column]): ConditionBuilder<'${tableName}'>;|]
                    where
                        record = tableNameToModelName tableName

recordInterface :: CreateTable -> Text
recordInterface CreateTable { name, columns } = "interface " <> tableNameToModelName name <> " {\n" <> fields <> "\n}"
    where
        fields = columns
                |> map columnToField
                |> intercalate "\n"
        columnToField Column { name, columnType, notNull } = "    " <> columnNameToFieldName name <> ": " <> columnTypeToTypeScript columnType notNull <> ";"

-- | Generates a record interface where fields with default values are optional
newRecordInterface :: CreateTable -> Text
newRecordInterface CreateTable { name, columns } = [trimming|
        /**
         * ${description}
         */
        interface New${modelName} {
            ${fields}
        }
    |]
    where
        fields = columns
                |> map columnToField
                |> intercalate "\n"
        columnToField Column { name, columnType, notNull, defaultValue } = columnNameToFieldName name <> (if isJust defaultValue then "?" else "") <> ": " <> columnTypeToTypeScript columnType notNull <> ";"

        modelName :: Text
        modelName = tableNameToModelName name

        description :: Text
        description = "A " <> modelName <> " object not yet inserted into the `" <> name <> "` table"

columnTypeToTypeScript :: PostgresType -> Bool -> Text
columnTypeToTypeScript sqlType notNull =
    if notNull
        then columnTypeToTypeScript' sqlType
        else columnTypeToTypeScript' sqlType <> " | null"

columnTypeToTypeScript' :: PostgresType -> Text
columnTypeToTypeScript' PText = "string"
columnTypeToTypeScript' PInt = "number"
columnTypeToTypeScript' PSmallInt = "number"
columnTypeToTypeScript' PDouble = "number"
columnTypeToTypeScript' PBoolean = "boolean"
columnTypeToTypeScript' PUUID = "UUID"
columnTypeToTypeScript' otherwise = "string"

newRecordType :: [CreateTable] -> Text
newRecordType createTableStatements = [trimming|
    type NewRecord<Type> = ${implementation};
|]
    where
        -- table extends "users" ? UserRecord : (table extends "tasks" ? TaskRecord : never)
        implementation = gen createTableStatements

        gen [] = "never"
        gen (table:rest) = "Type extends " <> tableNameToModelName (get #name table) <> " ? New" <> tableNameToModelName (get #name table) <> " : (" <> gen rest <> ")"


initIHPBackendTypeDef :: Project -> Text
initIHPBackendTypeDef project = [trimming|
    function initIHPBackend(options: { host: 'https://${backendHost}' | string | undefined; }): void;
|]
    where
        backendHost :: Text
        backendHost = get #domain project

mpscholten avatar Mar 16 '22 18:03 mpscholten