diff --git a/README.md b/README.md index e6c7c152..d9a50dbe 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Below is a categorized overview of all available Permit CLI commands: - [OpenAPI -x Extensions for Policy Configuration](#openapi--x-permit-extensions-for-policy-configuration) - [`permit env apply openapi`](#permit-env-apply-openapi) - Create a full policy schema in Permit by reading an OpenAPI spec file and using `-x-permit` extensions, enabling the use of OpenAPI schema as a source of authorization policy configuration. + - [`permit env apply trino`](#permit-env-apply-trino) - Create a full policy schema in Permit by introspecting a Trino cluster's database schema, including catalogs, schemas, tables, and columns. ### [Custom Rego (OPA) and GitOps](#custom-rego-opa-and-gitops-1) @@ -570,18 +571,99 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -#### URL Mapping +--- -After creating the policy elements based on the `-x-permit` extensions, the command will automatically create URL mappings in Permit. These mappings connect API endpoints to the appropriate resources and actions for runtime authorization checks. +### `env apply trino` -For each endpoint with the required extensions, a mapping rule will be created with: +This command introspects a Trino cluster and creates a full Permit policy schema by mapping catalogs, schemas, tables, views, materialized views, columns, functions, and procedures to Permit resources and actions. It is designed to work with any Trino-connected database (e.g., PostgreSQL, MySQL) and uses a robust passthrough strategy to discover all relevant objects. -- URL path from the OpenAPI spec -- HTTP method -- Resource from `x-permit-resource` -- Action from `x-permit-action` or the HTTP method +**Arguments (Required):** -This enables Permit to perform authorization checks directly against your API endpoints. +- `--url ` - Trino cluster URL (e.g., http://localhost:8080) +- `--user ` - Trino username + +**Arguments (Optional):** + +- `--api-key ` - API key for Permit authentication +- `--password ` - Trino password or authentication token +- `--catalog ` - Restrict to a specific catalog +- `--schema ` - Restrict to a specific schema + +**Examples:** + +Connect to Trino and sync all schemas: + +```bash +permit env apply trino --url http://localhost:8080 --user admin +``` + +Sync with authentication and a specific catalog: + +```bash +permit env apply trino --url http://localhost:8080 --user admin --password secret --catalog postgresql +``` + +Sync a specific schema with API key: + +```bash +permit env apply trino --url http://localhost:8080 --user admin --api-key permit_key --catalog postgresql --schema public +``` + +#### Resource Mapping + +- **Catalogs** → `trino-catalog-` (e.g., `trino-catalog-postgresql`) +- **Schemas** → `trino-schema--` +- **Tables** → `trino-table---` +- **Views** → `trino-view---` +- **Materialized Views** → `trino-materialized_view---` +- **Columns** → `trino-column---
-` +- **Functions** → `trino-function---` +- **Procedures** → `trino-procedure---` +- **System Resource** → `trino_sys` (Trino System resource for system-wide actions) + +Each table resource includes its columns as attributes with mapped data types: + +- `varchar`, `text` → `string` +- `integer`, `bigint`, `decimal` → `number` +- `boolean` → `bool` +- `timestamp`, `date` → `time` +- `json` → `json` +- `array` → `array` + +#### Actions + +- **Catalog actions:** `AccessCatalog`, `CreateCatalog`, `DropCatalog`, `FilterCatalogs` +- **Schema actions:** `CreateSchema`, `DropSchema`, `RenameSchema`, `SetSchemaAuthorization`, `ShowSchemas`, `FilterSchemas`, `ShowCreateSchema` +- **Table/Column actions:** `ShowCreateTable`, `CreateTable`, `DropTable`, `RenameTable`, `SetTableProperties`, `SetTableComment`, `AddColumn`, `AlterColumn`, `DropColumn`, `RenameColumn`, `SelectFromColumns`, `InsertIntoTable`, `DeleteFromTable`, `TruncateTable`, `UpdateTableColumns`, `ShowTables`, `FilterTables`, `ShowColumns`, `FilterColumns`, `SetTableAuthorization` +- **View actions:** `CreateView`, `RenameView`, `DropView`, `SetViewAuthorization`, `SetViewComment`, `CreateViewWithSelectFromColumns` +- **Materialized View actions:** `CreateMaterializedView`, `RefreshMaterializedView`, `SetMaterializedViewProperties`, `DropMaterializedView`, `RenameMaterializedView` +- **Function actions:** `ShowFunctions`, `FilterFunctions`, `ExecuteFunction`, `CreateFunction`, `DropFunction`, `ShowCreateFunction`, `CreateViewWithExecuteFunction` +- **Procedure actions:** `ExecuteProcedure`, `ExecuteTableProcedure` +- **System actions (on `trino_sys`):** + `ImpersonateUser`, `ExecuteQuery`, `ViewQueryOwnedBy`, `FilterViewQueryOwnedBy`, `KillQueryOwnedBy`, `ReadSystemInformation`, `WriteSystemInformation`, `SetSystemSessionProperty`, `GetRowFilters`, `GetColumnMask` + +#### Discovery Strategy + +- **Tables, Views, Materialized Views:** + Detected using a combination of Trino metadata, table comments, `SHOW CREATE TABLE`, and naming conventions. +- **Functions and Procedures:** + Discovered using Trino's passthrough feature (`TABLE(catalog.system.query(...))`) to query the underlying database's system tables. **Function and procedure discovery is only supported for PostgreSQL and MySQL (via passthrough); Trino UDFs and other database types are not supported.** For example, `pg_catalog.pg_proc` for PostgreSQL and `information_schema.routines` for MySQL. +- **System/Admin resources:** + By default, only user/business data resources are included. System/admin/internal catalogs and schemas are excluded unless explicitly requested. + +#### System Resource + +A special resource named `Trino System` with key `trino_sys` is always created, representing system-wide Trino actions. + +#### Testing + +Use the provided Docker Compose setup in `tests/trino/` for end-to-end testing: + +```bash +cd tests/trino +docker-compose up -d +permit env apply trino --url http://localhost:8080 --user test +``` --- @@ -1367,13 +1449,15 @@ paths: # ... ``` -A more detailed example [is available here](https://github.com/daveads/openapispec) +Check this repo for a good [example](https://github.com/daveads/openapispec) + +#### Complex Extension Objects For the more complex extensions that accept objects instead of strings, here's the expected structure: -- Object Structure: `x-permit-relation` +##### `x-permit-relation` Object Structure -``` +```json { "subject_resource": "string", // Required: The source resource in the relation "object_resource": "string", // Required: The target resource in the relation @@ -1382,9 +1466,9 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -- Object Structure: `x-permit-derived-role` +##### `x-permit-derived-role` Object Structure -``` +```json { "key": "string", // Optional: Unique identifier for the derived role "name": "string", // Optional: Human-readable name for the derived role @@ -1394,47 +1478,13 @@ For the more complex extensions that accept objects instead of strings, here's t } ``` -## Custom Rego (OPA) and GitOps - -Extend and customize authorization policies with GitOps flows and custom Rego logic. - -### Sync policies to Git repositories - -Export, version, and manage authorization policies as code: all through CLI commands - -#### `permit gitops create github` - -This command will configure your Permit environment to use the GitOps flow with GitHub. This is useful when you want to manage your policies in your own Git repository and extend them with custom policy code. - -**Arguments (Required)** - -- `--inactive ` - set the environment to inactive after configuring GitOps (`default:false`) - -**Example:** - -``` -gitops create github --inactive true -``` - --- -#### `permit gitops env clone` - -This clones the environment or the complete project from the active GitOps repository. - -**Arguments (Optional)** - -- `--api-key ` - The API key to select the project. The API Key is of the scope `Project`. -- `--dry-run` - Instead of executing the code, it displays the command to be executed. -- `--project` - Instead of selecting an environment branch to clone, it performs the standard clone operation. - -### Extend Predefined Policies with Custom Rego (Open Policy Agent) - -Use the CLI to modify and fine-tune Open Policy Agent (OPA) Rego policies while maintaining system stability. +### `opa` ---- +This collection of commands aims to create new experiences for developers working with Open Policy Agent (OPA) in their projects. -#### `permit opa policy` +### `opa policy` This command will print the available policies of an active OPA instance. This is useful when you want to see the policies in your OPA instance without fetching them from the OPA server. diff --git a/source/commands/env/apply/trino.tsx b/source/commands/env/apply/trino.tsx new file mode 100644 index 00000000..ea1d9db6 --- /dev/null +++ b/source/commands/env/apply/trino.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { option } from 'pastel'; +import zod from 'zod'; +import { AuthProvider } from '../../../components/AuthProvider.js'; +import TrinoComponent from '../../../components/env/trino/TrinoComponent.js'; +import type { TrinoOptions } from '../../../components/env/trino/types.js'; + +export const description = + 'Apply permissions policy from a Trino schema, creating resources from catalogs, schemas, tables, columns.'; + +export const options = zod.object({ + apiKey: zod + .string() + .optional() + .describe( + option({ + description: 'API key for Permit authentication', + alias: 'k', + }), + ), + url: zod.string().describe( + option({ + description: 'Trino cluster URL (e.g., http://localhost:8080)', + alias: 'u', + }), + ), + user: zod.string().describe( + option({ + description: 'Trino username', + }), + ), + password: zod + .string() + .optional() + .describe( + option({ + description: 'Trino password or authentication token', + alias: 'p', + }), + ), + catalog: zod + .string() + .optional() + .describe( + option({ + description: 'Restrict to a specific catalog', + alias: 'c', + }), + ), + schema: zod + .string() + .optional() + .describe( + option({ + description: 'Restrict to a specific schema', + alias: 's', + }), + ), +}); + +export default function Trino({ options }: { options: TrinoOptions }) { + return ( + + + + ); +} diff --git a/source/components/env/trino/TrinoComponent.tsx b/source/components/env/trino/TrinoComponent.tsx new file mode 100644 index 00000000..7b7b2e41 --- /dev/null +++ b/source/components/env/trino/TrinoComponent.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'ink'; +import type { TrinoOptions, PermitResource } from './types.js'; +import { useTrinoProcessor } from '../../../hooks/trino/useTrinoProcessor.js'; +import { mapTrinoSchemaToPermitResources } from '../../../utils/trinoUtils.js'; + +export default function TrinoComponent( + props: TrinoOptions, +): React.ReactElement { + const { processTrinoSchema, status, errorMessage } = useTrinoProcessor(); + const [createdResources, setCreatedResources] = useState( + [], + ); + + useEffect(() => { + (async () => { + const client = await import('../../../utils/trinoUtils.js'); + const { connectToTrino, fetchTrinoSchema } = client; + const trinoClient = connectToTrino(props); + const trinoSchema = await fetchTrinoSchema(trinoClient, { + catalog: props.catalog, + schema: props.schema, + }); + const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); + setCreatedResources(permitResources); + await processTrinoSchema(props); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (status === 'processing') { + return Processing Trino schema and syncing with Permit...; + } + if (status === 'error') { + return Error: {errorMessage}; + } + if (status === 'done') { + // Group resources by type + const grouped: Record = {}; + for (const r of createdResources) { + let type = ''; + if (r.key === 'trino_sys') { + type = 'System'; + } else { + const match = r.key.match(/^trino-([a-z_]+)/); + if (match && match[1]) { + type = match[1] + .replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()); + } else { + type = 'Other'; + } + } + if (!grouped[type]) { + grouped[type] = []; + } + const arr = grouped[type]!; + arr.push(r.name); + } + // Sort types in a preferred order + const typeOrder = [ + 'Catalog', + 'Schema', + 'Table', + 'View', + 'Materialized View', + 'Column', + 'Function', + 'Procedure', + 'System', + 'Other', + ]; + const sortedTypes = Object.keys(grouped).sort((a, b) => { + const ia = typeOrder.indexOf(a); + const ib = typeOrder.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); + return ( + <> + Trino schema successfully synced with Permit! + {sortedTypes.map(type => { + const items = grouped[type] ?? []; + return ( + + {type + 's'} ({items.length}) + {items + .sort((a, b) => a.localeCompare(b)) + .map(name => `\n - ${name}`) + .join('')} + + ); + })} + + ); + } + return Ready to process Trino schema...; +} diff --git a/source/components/env/trino/types.ts b/source/components/env/trino/types.ts new file mode 100644 index 00000000..413099dc --- /dev/null +++ b/source/components/env/trino/types.ts @@ -0,0 +1,29 @@ +export type TrinoOptions = { + apiKey?: string; + url: string; + user: string; + password?: string; + catalog?: string; + schema?: string; +}; + +export interface PermitResource { + key: string; + name: string; + description?: string; + actions: string[]; + attributes?: { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }; +} diff --git a/source/hooks/trino/useTrinoProcessor.ts b/source/hooks/trino/useTrinoProcessor.ts new file mode 100644 index 00000000..5b3da903 --- /dev/null +++ b/source/hooks/trino/useTrinoProcessor.ts @@ -0,0 +1,51 @@ +/** + * Hook for processing Trino schema extraction and mapping to Permit resources. + * Lint/prettier compliant, strict types, ready for implementation. + */ + +import { useCallback } from 'react'; +import { + connectToTrino, + fetchTrinoSchema, + mapTrinoSchemaToPermitResources, + TrinoSchemaData, +} from '../../utils/trinoUtils.js'; +import { useResourcesApi } from '../useResourcesApi.js'; +import type { TrinoOptions } from '../../components/env/trino/types.js'; + +export function useTrinoProcessor() { + const { createBulkResources, status, errorMessage } = useResourcesApi(); + + /** + * Main processing function. + * Connects to Trino, extracts schema, maps to Permit resources, and syncs with Permit. + */ + const processTrinoSchema = useCallback( + async (options: TrinoOptions): Promise => { + // 1. Connect to Trino + const client = connectToTrino(options); + + // 2. Fetch Trino schema + const trinoSchema: TrinoSchemaData = await fetchTrinoSchema(client, { + catalog: options.catalog, + schema: options.schema, + }); + + // 3. Map to Permit resources + const permitResources = mapTrinoSchemaToPermitResources(trinoSchema); + + // 4. Sync with Permit (omit 'type' property, ensure actions is an object) + await createBulkResources( + permitResources.map(({ actions, ...r }) => ({ + ...r, + actions: Object.fromEntries( + actions.map((action: string) => [action, {}]), + ), + })), + ); + }, + [createBulkResources], + ); + + return { processTrinoSchema, status, errorMessage }; +} diff --git a/source/hooks/useResourcesApi.ts b/source/hooks/useResourcesApi.ts index 98c0978d..cb592983 100644 --- a/source/hooks/useResourcesApi.ts +++ b/source/hooks/useResourcesApi.ts @@ -20,32 +20,42 @@ export const useResourcesApi = () => { const getExistingResources = useCallback(async () => { try { const client = authenticatedApiClient(); - const result = await client.GET( - `/v2/schema/{proj_id}/{env_id}/resources`, - ); - const error = result.error; - - if (error) throw new Error(error); - if (!result.data) { - setErrorMessage('No resources found'); - return new Set(); - } + let allResources: { key: string }[] = []; + let page = 1; + const perPage = 100; + while (true) { + const result = await client.GET( + `/v2/schema/{proj_id}/{env_id}/resources`, + undefined, + undefined, + { page, per_page: perPage }, + ); + const error = result.error; - type Resource = { key: string }; + if (error) throw new Error(error); + if (!result.data) { + setErrorMessage('No resources found'); + break; + } - const raw = result.data; - let resources: Resource[] = []; + let resources: { key: string }[] = []; + const raw = result.data; + if (Array.isArray(raw)) { + resources = raw; + } else if (raw && Array.isArray(raw.data)) { + resources = raw.data; + } else { + setErrorMessage('Invalid resource data format'); + break; + } - if (Array.isArray(raw)) { - resources = raw; - } else if (raw && Array.isArray(raw.data)) { - resources = raw.data; - } else { - setErrorMessage('Invalid resource data format'); - return new Set(); + allResources = allResources.concat(resources); + if (resources.length < perPage) { + break; + } + page++; } - - return new Set(resources.map(r => r.key)); + return new Set(allResources.map(r => r.key)); } catch (error) { setErrorMessage((error as Error).message); return new Set(); diff --git a/source/utils/trinoUtils.ts b/source/utils/trinoUtils.ts new file mode 100644 index 00000000..6a951e4c --- /dev/null +++ b/source/utils/trinoUtils.ts @@ -0,0 +1,762 @@ +/** + * Utility functions for connecting to Trino and extracting schema information. + * All functions are lint/prettier compliant and ready for implementation. + */ + +import type { PermitResource } from '../components/env/trino/types.js'; + +export interface TrinoColumn { + name: string; + type: string; + nullable: boolean; +} + +export interface TrinoTable { + catalog: string; + schema: string; + name: string; + type: string; + columns: TrinoColumn[]; +} + +export interface TrinoSchema { + catalog: string; + name: string; +} + +export interface TrinoCatalog { + name: string; +} + +export interface TrinoFunction { + catalog: string; + schema: string; + name: string; + returnType: string; + argumentTypes: string[]; +} + +export interface TrinoView { + catalog: string; + schema: string; + name: string; + columns: TrinoColumn[]; +} + +export interface TrinoMaterializedView { + catalog: string; + schema: string; + name: string; + columns: TrinoColumn[]; +} + +export interface TrinoProcedure { + catalog: string; + schema: string; + name: string; + argumentTypes: string[]; +} + +export interface TrinoSchemaData { + catalogs: TrinoCatalog[]; + schemas: TrinoSchema[]; + tables: TrinoTable[]; + functions: TrinoFunction[]; + views: TrinoView[]; + materializedViews: TrinoMaterializedView[]; + procedures: TrinoProcedure[]; +} + +/** + * Map Trino column type to Permit attribute type. + */ +export function trinoTypeToPermitType( + trinoType: string, +): + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array' { + const t = trinoType.toLowerCase(); + if (t.includes('char') || t === 'uuid' || t === 'varchar') return 'string'; + if (t === 'boolean') return 'bool'; + if ( + t === 'integer' || + t === 'int' || + t === 'bigint' || + t === 'smallint' || + t === 'tinyint' || + t === 'double' || + t === 'real' || + t === 'float' || + t === 'decimal' + ) + return 'number'; + if (t === 'json') return 'json'; + if (t === 'array') return 'array'; + if (t === 'object' || t === 'row') return 'object'; + if (t === 'timestamp' || t === 'date' || t === 'time') return 'time'; + return 'string'; +} + +/** + * Map Trino schema data to Permit resources. + * - Each catalog, schema, table, and column is a resource. + * - Each table resource includes columns as attributes (with type/description). + */ +export function mapTrinoSchemaToPermitResources( + trino: TrinoSchemaData, +): PermitResource[] { + const resources: PermitResource[] = []; + const SEP = '-'; + + // Catalogs + for (const catalog of trino.catalogs) { + resources.push({ + key: `trino${SEP}catalog${SEP}${catalog.name}`, + name: catalog.name, + description: `Trino resource type: catalog. Trino catalog: ${catalog.name}`, + actions: [ + 'AccessCatalog', + 'CreateCatalog', + 'DropCatalog', + 'FilterCatalogs', + ], + }); + } + + // Schemas + for (const schema of trino.schemas) { + resources.push({ + key: `trino${SEP}schema${SEP}${schema.catalog}${SEP}${schema.name}`, + name: `${schema.catalog}.${schema.name}`, + description: `Trino resource type: schema. Schema ${schema.name} in catalog ${schema.catalog}`, + actions: [ + 'CreateSchema', + 'DropSchema', + 'RenameSchema', + 'SetSchemaAuthorization', + 'ShowSchemas', + 'FilterSchemas', + 'ShowCreateSchema', + ], + }); + } + + const TABLE_AND_COLUMN_ACTIONS = [ + 'ShowCreateTable', + 'CreateTable', + 'DropTable', + 'RenameTable', + 'SetTableProperties', + 'SetTableComment', + 'AddColumn', + 'AlterColumn', + 'DropColumn', + 'RenameColumn', + 'SelectFromColumns', + 'InsertIntoTable', + 'DeleteFromTable', + 'TruncateTable', + 'UpdateTableColumns', + 'ShowTables', + 'FilterTables', + 'ShowColumns', + 'FilterColumns', + 'SetTableAuthorization', + ]; + + // Tables and columns + for (const table of trino.tables) { + const tableKey = `trino${SEP}table${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}`; + resources.push({ + key: tableKey, + name: `${table.catalog}.${table.schema}.${table.name}`, + description: `Trino resource type: ${table.type.toLowerCase()}. ${table.type} ${table.name} in ${table.catalog}.${table.schema}`, + actions: TABLE_AND_COLUMN_ACTIONS, + attributes: table.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + // Columns as resources + for (const column of table.columns) { + resources.push({ + key: `trino${SEP}column${SEP}${table.catalog}${SEP}${table.schema}${SEP}${table.name}${SEP}${column.name}`, + name: `${table.catalog}.${table.schema}.${table.name}.${column.name}`, + description: `Trino resource type: column. Column ${column.name} in ${table.catalog}.${table.schema}.${table.name}`, + actions: TABLE_AND_COLUMN_ACTIONS, + attributes: { + parent_table: { + type: 'string', + description: `${table.catalog}.${table.schema}.${table.name}`, + }, + table_type: { type: 'string', description: table.type.toLowerCase() }, + type: { + type: trinoTypeToPermitType(column.type), + description: column.type, + }, + nullable: { + type: 'bool', + description: column.nullable ? 'nullable' : undefined, + }, + }, + }); + } + } + + // Views + for (const view of trino.views) { + resources.push({ + key: `trino${SEP}view${SEP}${view.catalog}${SEP}${view.schema}${SEP}${view.name}`, + name: `${view.catalog}.${view.schema}.${view.name}`, + description: `Trino resource type: view. View ${view.name} in ${view.catalog}.${view.schema}`, + actions: [ + 'CreateView', + 'RenameView', + 'DropView', + 'SetViewAuthorization', + 'SetViewComment', + 'CreateViewWithSelectFromColumns', + ], + attributes: view.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + } + + // Materialized Views + for (const mview of trino.materializedViews) { + resources.push({ + key: `trino${SEP}materialized_view${SEP}${mview.catalog}${SEP}${mview.schema}${SEP}${mview.name}`, + name: `${mview.catalog}.${mview.schema}.${mview.name}`, + description: `Trino resource type: materialized view. Materialized view ${mview.name} in ${mview.catalog}.${mview.schema}`, + actions: [ + 'CreateMaterializedView', + 'RefreshMaterializedView', + 'SetMaterializedViewProperties', + 'DropMaterializedView', + 'RenameMaterializedView', + ], + attributes: mview.columns.reduce( + (acc, col) => { + acc[col.name] = { + type: trinoTypeToPermitType(col.type), + description: col.nullable ? 'nullable' : undefined, + }; + return acc; + }, + {} as { + [key: string]: { + type: + | 'string' + | 'number' + | 'object' + | 'json' + | 'time' + | 'bool' + | 'array' + | 'object_array'; + description?: string; + }; + }, + ), + }); + } + + // Functions + for (const fn of trino.functions) { + resources.push({ + key: `trino${SEP}function${SEP}${fn.catalog}${SEP}${fn.schema}${SEP}${fn.name}`, + name: `${fn.catalog}.${fn.schema}.${fn.name}`, + description: `Trino resource type: function. Function ${fn.name} in ${fn.catalog}.${fn.schema}`, + actions: [ + 'ShowFunctions', + 'FilterFunctions', + 'ExecuteFunction', + 'CreateFunction', + 'DropFunction', + 'ShowCreateFunction', + 'CreateViewWithExecuteFunction', + ], + attributes: { + returnType: { type: trinoTypeToPermitType(fn.returnType) }, + argumentTypes: { type: 'array' }, + }, + }); + } + + // Procedures + for (const proc of trino.procedures) { + resources.push({ + key: `trino${SEP}procedure${SEP}${proc.catalog}${SEP}${proc.schema}${SEP}${proc.name}`, + name: `${proc.catalog}.${proc.schema}.${proc.name}`, + description: `Trino resource type: procedure. Procedure ${proc.name} in ${proc.catalog}.${proc.schema}`, + actions: ['ExecuteProcedure', 'ExecuteTableProcedure'], + attributes: { + argumentTypes: { type: 'array' }, + }, + }); + } + + // Add Trino System resource + resources.push({ + key: 'trino_sys', + name: 'Trino System', + description: 'Trino system-level resource for system-wide actions.', + actions: [ + 'ImpersonateUser', + 'ExecuteQuery', + 'ViewQueryOwnedBy', + 'FilterViewQueryOwnedBy', + 'KillQueryOwnedBy', + 'ReadSystemInformation', + 'WriteSystemInformation', + 'SetSystemSessionProperty', + 'GetRowFilters', + 'GetColumnMask', + ], + }); + + return resources; +} + +// Connect to a Trino cluster (returns a client config) +export function connectToTrino(options: { + url: string; + user: string; + password?: string; +}): { baseUrl: string; headers: Record } { + const headers: Record = { + 'X-Trino-User': options.user, + 'X-Trino-Source': 'permit-cli', + }; + if (options.password) { + // Use btoa for base64 encoding + headers['Authorization'] = + 'Basic ' + btoa(`${options.user}:${options.password}`); + } + return { + baseUrl: options.url.replace(/\/$/, ''), + headers, + }; +} + +// Helper to execute a Trino query and return all rows +async function executeTrinoQuery( + client: { baseUrl: string; headers: Record }, + query: string, +): Promise { + const res = await fetch(`${client.baseUrl}/v1/statement`, { + method: 'POST', + headers: { + ...client.headers, + 'Content-Type': 'text/plain', + }, + body: query, + }); + if (!res.ok) + throw new Error(`Trino query failed: ${res.status} ${res.statusText}`); + let data = await res.json(); + let rows: string[][] = data.data || []; + let nextUri = data.nextUri; + while (nextUri) { + const nextRes = await fetch(nextUri, { headers: client.headers }); + const nextData = await nextRes.json(); + if (nextData.data) rows = rows.concat(nextData.data); + nextUri = nextData.nextUri; + } + return rows; +} + +// Helper: Use Trino passthrough table function to fetch functions/procedures +export async function fetchTrinoFunctionsAndProceduresPassthrough( + client: { baseUrl: string; headers: Record }, + catalog: string, + schema: string, +): Promise<{ + functions: TrinoFunction[]; + procedures: TrinoProcedure[]; +}> { + const functions: TrinoFunction[] = []; + const procedures: TrinoProcedure[] = []; + + if (catalog.toLowerCase() === 'postgresql') { + const passthrough = `SELECT * FROM TABLE(postgresql.system.query(query => ' + SELECT p.proname as function_name, n.nspname as schema_name, + pg_catalog.pg_get_function_result(p.oid) as return_type, + pg_catalog.pg_get_function_arguments(p.oid) as arguments, + CASE p.prokind + WHEN ''f'' THEN ''FUNCTION'' + WHEN ''p'' THEN ''PROCEDURE'' + WHEN ''a'' THEN ''AGGREGATE'' + WHEN ''w'' THEN ''WINDOW'' + ELSE p.prokind::text + END as kind + FROM pg_catalog.pg_proc p + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ''${schema}'' + AND p.proname NOT LIKE ''pg_%'' + ORDER BY p.proname + '))`; + try { + const rows = await executeTrinoQuery(client, passthrough); + for (const row of rows) { + const [name, schemaName, returnType, args, kind] = row; + if (kind === 'FUNCTION') { + functions.push({ + catalog: catalog || '', + schema: schemaName || '', + name: name || '', + returnType: returnType || 'unknown', + argumentTypes: args + ? args.split(',').map((a: string) => a.trim()) + : [], + }); + } else if (kind === 'PROCEDURE') { + procedures.push({ + catalog: catalog || '', + schema: schemaName || '', + name: name || '', + argumentTypes: args + ? args.split(',').map((a: string) => a.trim()) + : [], + }); + } + } + } catch (e) { + // console.log('[DEBUG] Passthrough for PostgreSQL failed:', e); + } + } else if (catalog.toLowerCase() === 'mysql') { + const passthrough = `SELECT * FROM TABLE(mysql.system.query(query => ' + SELECT routine_name, routine_type, data_type, routine_definition + FROM information_schema.routines + WHERE routine_schema = ''${schema}'' + '))`; + try { + const rows = await executeTrinoQuery(client, passthrough); + for (const row of rows) { + const [name, routineType, returnType, _def] = row; + if ( + routineType && + routineType.trim().toUpperCase().startsWith('FUNCTION') + ) { + functions.push({ + catalog: catalog || '', + schema: schema || '', + name: name || '', + returnType: returnType || 'unknown', + argumentTypes: [], + }); + } else if ( + routineType && + routineType.trim().toUpperCase().startsWith('PROCEDURE') + ) { + procedures.push({ + catalog: catalog || '', + schema: schema || '', + name: name || '', + argumentTypes: [], + }); + } + } + } catch (e) { + // console.log('[DEBUG] Passthrough for MySQL failed:', e); + } + } + return { functions, procedures }; +} + +// Fetch catalogs, schemas, tables, and columns from Trino +export async function fetchTrinoSchema( + client: { baseUrl: string; headers: Record }, + options: { catalog?: string; schema?: string }, +): Promise { + // 1. Catalogs + const catalogQuery = options.catalog + ? `SHOW CATALOGS LIKE '${options.catalog}'` + : 'SHOW CATALOGS'; + const catalogRows = await executeTrinoQuery(client, catalogQuery); + let catalogs: TrinoCatalog[] = catalogRows + .map(row => ({ name: row[0] ?? '' })) + .filter(c => c.name); + // Always exclude system/admin/internal catalogs + const adminCatalogs = new Set(['system', 'information_schema']); + catalogs = catalogs.filter(c => !adminCatalogs.has(c.name.toLowerCase())); + + // 2. Schemas + let schemas: TrinoSchema[] = []; + for (const catalog of catalogs) { + const schemaQuery = options.schema + ? `SHOW SCHEMAS FROM ${catalog.name} LIKE '${options.schema}'` + : `SHOW SCHEMAS FROM ${catalog.name}`; + const schemaRows = await executeTrinoQuery(client, schemaQuery); + let theseSchemas = schemaRows + .map(row => ({ catalog: catalog.name, name: row[0] ?? '' })) + .filter(s => s.name); + // Always exclude system/admin/internal schemas + const adminSchemas = new Set([ + 'information_schema', + 'sys', + 'performance_schema', + 'mysql', + 'pg_catalog', + 'system', + ]); + theseSchemas = theseSchemas.filter( + s => !adminSchemas.has(s.name.toLowerCase()), + ); + schemas.push(...theseSchemas); + } + + // 3. Get table comments to help identify object types + const tableComments = new Map(); + try { + const commentsQuery = ` + SELECT catalog_name, schema_name, table_name, comment + FROM system.metadata.table_comments + WHERE comment IS NOT NULL + `; + const commentRows = await executeTrinoQuery(client, commentsQuery); + for (const row of commentRows) { + const [catalog, schema, table, comment] = row; + if (catalog && schema && table && comment) { + const key = `${catalog}.${schema}.${table}`; + tableComments.set(key, comment); + } + } + } catch (_) { + // console.log('[DEBUG] Could not fetch table comments'); + } + + // 4. Tables, Views, and Materialized Views + const tables: TrinoTable[] = []; + const views: TrinoView[] = []; + const materializedViews: TrinoMaterializedView[] = []; + + for (const schema of schemas) { + // Get all objects from information_schema + const tableQuery = `SELECT table_name, table_type FROM ${schema.catalog}.information_schema.tables WHERE table_schema = '${schema.name}'`; + const tableRows = await executeTrinoQuery(client, tableQuery); + + for (const [tableNameRaw, tableTypeRaw] of tableRows) { + const tableName = tableNameRaw ?? ''; + const reportedType = tableTypeRaw ?? ''; + if (!tableName) continue; + + // Get columns first + let columns: TrinoColumn[] = []; + try { + const columnsQuery = `SHOW COLUMNS FROM ${schema.catalog}.${schema.name}.${tableName}`; + const columnRows = await executeTrinoQuery(client, columnsQuery); + columns = columnRows + .map(row => ({ + name: row[0] ?? '', + type: row[1] ?? '', + nullable: row[3] !== 'NO', // Column 3 is Null (YES/NO) + })) + .filter(col => col.name && col.type); + } catch (_) { + // console.log(`[DEBUG] Failed to get columns for ${tableName}`); + } + + // Determine actual object type using multiple strategies + let actualType: 'TABLE' | 'VIEW' | 'MATERIALIZED VIEW' = 'TABLE'; + + const VIEW_TYPE = 'VIEW'; + const MATERIALIZED_VIEW_TYPE = 'MATERIALIZED VIEW'; + + // Strategy 1: Check table comments (most reliable for MySQL) + const commentKey = `${schema.catalog}.${schema.name}.${tableName}`; + const comment = tableComments.get(commentKey); + if (comment) { + if (comment.toUpperCase() === 'VIEW') { + actualType = VIEW_TYPE; + } else if (comment.toUpperCase().includes('MATERIALIZED')) { + actualType = MATERIALIZED_VIEW_TYPE; + } + } + + // Strategy 2: Check if reported type gives us a hint (some connectors might work) + if (actualType === 'TABLE' && reportedType.toUpperCase() === VIEW_TYPE) { + actualType = VIEW_TYPE; + } else if ( + actualType === 'TABLE' && + reportedType.toUpperCase() === MATERIALIZED_VIEW_TYPE + ) { + actualType = MATERIALIZED_VIEW_TYPE; + } + + // Strategy 3: Try to get CREATE statement to determine type + if (actualType === 'TABLE') { + try { + const createQuery = `SHOW CREATE TABLE ${schema.catalog}.${schema.name}.${tableName}`; + const createRows = await executeTrinoQuery(client, createQuery); + if (createRows.length > 0 && createRows[0] && createRows[0][0]) { + const createStatement = createRows[0][0].toString(); + const upperStatement = createStatement.toUpperCase(); + + // Check for VIEW patterns + if ( + upperStatement.includes('CREATE VIEW') || + upperStatement.includes('CREATE OR REPLACE VIEW') + ) { + actualType = VIEW_TYPE; + } else if (upperStatement.includes('CREATE MATERIALIZED VIEW')) { + actualType = MATERIALIZED_VIEW_TYPE; + } else if ( + upperStatement.includes('SELECT') && + upperStatement.includes('FROM') && + !upperStatement.includes('CREATE TABLE') + ) { + // Sometimes views are shown as CREATE TABLE but contain SELECT...FROM + actualType = VIEW_TYPE; + } + } + } catch (_) { + // Ignore errors from SHOW CREATE TABLE + } + } + + // Strategy 4: Compare column counts (views often have fewer columns than source tables) + if (actualType === 'TABLE' && columns.length > 0) { + try { + // Check if this might be a view by looking for a table with similar name but more columns + const baseName = tableName + .replace(/_view$|_v$|_mv$|_materialized$/i, '') + .replace(/^active_|^v_/i, ''); + if (baseName !== tableName) { + // This table has a view-like name, check if base table exists + const baseTableQuery = `SELECT COUNT(*) FROM ${schema.catalog}.information_schema.columns WHERE table_schema = '${schema.name}' AND table_name = '${baseName}'`; + const baseResult = await executeTrinoQuery(client, baseTableQuery); + if ( + baseResult.length > 0 && + baseResult[0] && + baseResult[0][0] !== undefined + ) { + const baseColumnCount = Number(baseResult[0][0]); + if (baseColumnCount > columns.length) { + actualType = VIEW_TYPE; + } + } + } + } catch (_) { + // Ignore errors + } + } + + // Strategy 5: Use naming conventions as a final fallback + if (actualType === 'TABLE') { + const lowerName = tableName.toLowerCase(); + if ( + lowerName.endsWith('_view') || + lowerName.endsWith('_v') || + lowerName.includes('_view_') || + lowerName.startsWith('v_') || + lowerName.startsWith('active_') + ) { + actualType = VIEW_TYPE; + } else if ( + lowerName.endsWith('_mv') || + lowerName.endsWith('_materialized') || + lowerName.includes('_mv_') + ) { + actualType = MATERIALIZED_VIEW_TYPE; + } + } + + // Add to appropriate collection + + if (actualType === VIEW_TYPE) { + views.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + columns, + }); + } else if (actualType === MATERIALIZED_VIEW_TYPE) { + materializedViews.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + columns, + }); + } else { + tables.push({ + catalog: schema.catalog, + schema: schema.name, + name: tableName, + type: 'BASE TABLE', + columns, + }); + } + } + } + + // 5. Functions - Note: Most Trino connectors don't expose functions + let functions: TrinoFunction[] = []; + let procedures: TrinoProcedure[] = []; + for (const schema of schemas) { + const { functions: passthroughFuncs, procedures: passthroughProcs } = + await fetchTrinoFunctionsAndProceduresPassthrough( + client, + schema.catalog, + schema.name, + ); + functions = functions.concat(passthroughFuncs); + procedures = procedures.concat(passthroughProcs); + } + + return { + catalogs, + schemas, + tables, + functions, + views, + materializedViews, + procedures, + }; +} diff --git a/tests/cli.test.tsx b/tests/cli.test.tsx index e739e8d2..4ddf8e6c 100644 --- a/tests/cli.test.tsx +++ b/tests/cli.test.tsx @@ -4,6 +4,7 @@ vi.mock('pastel', () => ({ default: vi.fn().mockImplementation(() => ({ run: vi.fn(() => Promise.resolve()), })), + option: vi.fn(config => config), })); import Pastel from 'pastel'; @@ -12,7 +13,7 @@ describe('Cli script', () => { it('Should run the pastel app', async () => { await import('../source/cli.js'); expect(Pastel).toHaveBeenCalled(); - const pastelInstance = Pastel.mock.results[0].value; + const pastelInstance = (Pastel as any).mock.results[0].value; expect(pastelInstance.run).toHaveBeenCalled(); }); }); diff --git a/tests/components/env/trino/TrinoComponent.test.tsx b/tests/components/env/trino/TrinoComponent.test.tsx new file mode 100644 index 00000000..2774c4e2 --- /dev/null +++ b/tests/components/env/trino/TrinoComponent.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TrinoComponent from '../../../../source/components/env/trino/TrinoComponent.js'; +import type { TrinoOptions } from '../../../../source/components/env/trino/types.js'; + +// Mock the useTrinoProcessor hook +vi.mock('../../../../source/hooks/trino/useTrinoProcessor.js', () => ({ + useTrinoProcessor: vi.fn(), +})); + +// Mock the dynamic import of trinoUtils +vi.mock('../../../../source/utils/trinoUtils.js', () => ({ + connectToTrino: vi.fn(() => ({})), + fetchTrinoSchema: vi.fn(() => ({})), + mapTrinoSchemaToPermitResources: vi.fn(() => []), +})); + +import { useTrinoProcessor } from '../../../../source/hooks/trino/useTrinoProcessor.js'; + +describe('TrinoComponent', () => { + const mockOptions: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + password: 'testpass', + catalog: 'postgresql', + schema: 'public', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render ready state initially', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'idle', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Ready to process Trino schema'); + }); + + it('should render processing state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'processing', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain( + 'Processing Trino schema and syncing with Permit', + ); + }); + + it('should render error state with error message', () => { + const errorMessage = 'Connection failed'; + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'error', + errorMessage, + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Error: Connection failed'); + }); + + it('should render success state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'done', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain( + 'Trino schema successfully synced with Permit', + ); + }); + + it('should call processTrinoSchema on mount with props', async () => { + const mockProcessTrinoSchema = vi.fn(); + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: mockProcessTrinoSchema, + status: 'idle', + errorMessage: '', + }); + + render(); + await vi.waitFor(() => { + expect(mockProcessTrinoSchema).toHaveBeenCalledWith(mockOptions); + }); + }); + + it('should handle undefined status gracefully', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: undefined, + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Ready to process Trino schema'); + }); + + it('should handle empty error message in error state', () => { + (useTrinoProcessor as any).mockReturnValue({ + processTrinoSchema: vi.fn(), + status: 'error', + errorMessage: '', + }); + + const { lastFrame } = render(); + expect(lastFrame()).toContain('Error:'); + }); +}); diff --git a/tests/env/apply/trino.test.tsx b/tests/env/apply/trino.test.tsx new file mode 100644 index 00000000..5ca2e658 --- /dev/null +++ b/tests/env/apply/trino.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { describe, it, expect, vi } from 'vitest'; +import Trino from '../../../source/commands/env/apply/trino.js'; + +vi.mock('../../../source/components/AuthProvider.js', () => { + return { + __esModule: true, + AuthProvider: function AuthProvider({ + children, + }: { + children: React.ReactNode; + }) { + return <>{children}; + }, + }; +}); + +vi.mock('../../../source/components/env/trino/TrinoComponent.js', () => ({ + __esModule: true, + default: ({ url, user }: { url: string; user: string }) => ( + + TrinoComponentMock url={url} user={user} + + ), +})); + +describe('permit env apply trino CLI command', () => { + it('renders the TrinoComponent and passes props', async () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('TrinoComponentMock'); + expect(lastFrame()).toContain('testuser'); + }); +}); diff --git a/tests/hooks/trino/useTrinoProcessor.test.tsx b/tests/hooks/trino/useTrinoProcessor.test.tsx new file mode 100644 index 00000000..505a38d4 --- /dev/null +++ b/tests/hooks/trino/useTrinoProcessor.test.tsx @@ -0,0 +1,358 @@ +import React from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useTrinoProcessor } from '../../../source/hooks/trino/useTrinoProcessor.js'; +import type { TrinoOptions } from '../../../source/components/env/trino/types.js'; + +// Mock the trinoUtils functions +vi.mock('../../../source/utils/trinoUtils.js', () => ({ + connectToTrino: vi.fn(), + fetchTrinoSchema: vi.fn(), + mapTrinoSchemaToPermitResources: vi.fn(), +})); + +// Mock the useResourcesApi hook +vi.mock('../../../source/hooks/useResourcesApi.js', () => ({ + useResourcesApi: vi.fn(), +})); + +import { + connectToTrino, + fetchTrinoSchema, + mapTrinoSchemaToPermitResources, +} from '../../../source/utils/trinoUtils.js'; +import { useResourcesApi } from '../../../source/hooks/useResourcesApi.js'; + +describe('useTrinoProcessor', () => { + const mockOptions: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + password: 'testpass', + catalog: 'postgresql', + schema: 'public', + insecure: false, + }; + + const mockTrinoClient = { + baseUrl: 'http://localhost:8080', + headers: { 'X-Trino-User': 'testuser' }, + }; + + const mockTrinoSchema = { + catalogs: [{ name: 'postgresql' }], + schemas: [{ catalog: 'postgresql', name: 'public' }], + tables: [ + { + catalog: 'postgresql', + schema: 'public', + name: 'users', + type: 'BASE TABLE', + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'email', type: 'varchar', nullable: false }, + ], + }, + ], + }; + + const mockPermitResources = [ + { + key: 'postgresql', + name: 'postgresql', + description: 'Trino catalog: postgresql', + actions: ['access_catalog', 'show_schemas'], + }, + { + key: 'postgresql|public|users', + name: 'postgresql.public.users', + description: 'BASE TABLE users in postgresql.public', + actions: ['select', 'insert', 'update', 'delete'], + attributes: { + id: { type: 'number', description: undefined }, + email: { type: 'string', description: undefined }, + }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should process Trino schema successfully', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).toHaveBeenCalledWith( + mockTrinoSchema, + ); + expect(mockCreateBulkResources).toHaveBeenCalledWith([ + { + key: 'postgresql', + name: 'postgresql', + description: 'Trino catalog: postgresql', + actions: { access_catalog: {}, show_schemas: {} }, + }, + { + key: 'postgresql|public|users', + name: 'postgresql.public.users', + description: 'BASE TABLE users in postgresql.public', + actions: { select: {}, insert: {}, update: {}, delete: {} }, + attributes: { + id: { type: 'number', description: undefined }, + email: { type: 'string', description: undefined }, + }, + }, + ]); + }); + + it('should handle connection errors', async () => { + const mockCreateBulkResources = vi.fn(); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + const connectionError = new Error('Connection failed'); + (connectToTrino as any).mockImplementation(() => { + throw connectionError; + }); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).not.toHaveBeenCalled(); + expect(mapTrinoSchemaToPermitResources).not.toHaveBeenCalled(); + expect(mockCreateBulkResources).not.toHaveBeenCalled(); + }); + + it('should handle schema fetching errors', async () => { + const mockCreateBulkResources = vi.fn(); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + const fetchError = new Error('Schema fetch failed'); + (fetchTrinoSchema as any).mockRejectedValue(fetchError); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).not.toHaveBeenCalled(); + expect(mockCreateBulkResources).not.toHaveBeenCalled(); + }); + + it('should handle resource creation errors', async () => { + const mockCreateBulkResources = vi + .fn() + .mockRejectedValue(new Error('API failed')); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('error'); + }); + + expect(connectToTrino).toHaveBeenCalledWith(mockOptions); + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: 'postgresql', + schema: 'public', + }); + expect(mapTrinoSchemaToPermitResources).toHaveBeenCalledWith( + mockTrinoSchema, + ); + expect(mockCreateBulkResources).toHaveBeenCalled(); + }); + + it('should handle options without catalog and schema', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue(mockTrinoSchema); + (mapTrinoSchemaToPermitResources as any).mockReturnValue( + mockPermitResources, + ); + + const optionsWithoutFilters: TrinoOptions = { + url: 'http://localhost:8080', + user: 'testuser', + }; + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(optionsWithoutFilters) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(fetchTrinoSchema).toHaveBeenCalledWith(mockTrinoClient, { + catalog: undefined, + schema: undefined, + }); + }); + + it('should return status and error message from useResourcesApi', () => { + (useResourcesApi as any).mockReturnValue({ + createBulkResources: vi.fn(), + status: 'processing', + errorMessage: 'Test error', + }); + + const TestComponent = () => { + const { status, errorMessage } = useTrinoProcessor(); + return {`${status}:${errorMessage}`}; + }; + + const { lastFrame } = render(); + expect(lastFrame()).toBe('processing:Test error'); + }); + + it('should handle empty schema data', async () => { + const mockCreateBulkResources = vi.fn().mockResolvedValue(undefined); + (useResourcesApi as any).mockReturnValue({ + createBulkResources: mockCreateBulkResources, + status: 'idle', + errorMessage: '', + }); + + (connectToTrino as any).mockReturnValue(mockTrinoClient); + (fetchTrinoSchema as any).mockResolvedValue({ + catalogs: [], + schemas: [], + tables: [], + }); + (mapTrinoSchemaToPermitResources as any).mockReturnValue([]); + + const TestComponent = () => { + const { processTrinoSchema } = useTrinoProcessor(); + const [result, setResult] = React.useState(''); + + React.useEffect(() => { + processTrinoSchema(mockOptions) + .then(() => setResult('success')) + .catch(() => setResult('error')); + }, []); + + return {result}; + }; + + const { lastFrame } = render(); + await vi.waitFor(() => { + expect(lastFrame()).toBe('success'); + }); + + expect(mockCreateBulkResources).toHaveBeenCalledWith([]); + }); +}); diff --git a/tests/hooks/useResourceApi.test.tsx b/tests/hooks/useResourceApi.test.tsx index e7ca5d5d..f8e986a8 100644 --- a/tests/hooks/useResourceApi.test.tsx +++ b/tests/hooks/useResourceApi.test.tsx @@ -101,6 +101,9 @@ describe('useResourceApi', () => { expect(lastFrame()).toContain('Result: users,posts'); expect(mockGetFn).toHaveBeenCalledWith( '/v2/schema/{proj_id}/{env_id}/resources', + undefined, + undefined, + { page: 1, per_page: 100 }, ); }); diff --git a/tests/trino/README.md b/tests/trino/README.md new file mode 100644 index 00000000..1e14bd2e --- /dev/null +++ b/tests/trino/README.md @@ -0,0 +1,93 @@ +# Trino Test Environment + +This directory contains a Docker Compose setup for testing the `permit env apply trino` command with a real Trino cluster and sample databases. + +## Setup + +1. **Start the environment:** + + ```bash + cd tests/trino + docker-compose up -d + ``` + +2. **Wait for services to be healthy:** + + ```bash + docker-compose ps + ``` + + All services should show "healthy" status. + +3. **Verify Trino is accessible:** + ```bash + curl http://localhost:8080/v1/info + ``` + +## Test Data + +The setup includes two databases with sample data: + +### PostgreSQL (catalog: postgresql) + +- **Schema:** public +- **Tables:** users, products, orders, order_items +- **Sample data:** 3 users, 4 products, 3 orders + +### MySQL (catalog: mysql) + +- **Schema:** testdb +- **Tables:** customers, inventory, transactions, transaction_items +- **Sample data:** 3 customers, 4 inventory items, 3 transactions + +## Testing the CLI Command + +Once the environment is running, you can test the `permit env apply trino` command: + +```bash +# Test with all catalogs +permit env apply trino --url http://localhost:8080 --user test + +# Test with specific catalog +permit env apply trino --url http://localhost:8080 --user test --catalog postgresql + +# Test with specific schema +permit env apply trino --url http://localhost:8080 --user test --catalog postgresql --schema public +``` + +## Expected Resources + +The command should create the following Permit resources: + +### Catalogs + +- `postgresql` - PostgreSQL catalog +- `mysql` - MySQL catalog + +### Schemas + +- `postgresql|public` - Public schema in PostgreSQL +- `mysql|testdb` - TestDB schema in MySQL + +### Tables (with columns as attributes) + +- `postgresql|public|users` - Users table with columns as attributes +- `postgresql|public|products` - Products table with columns as attributes +- `postgresql|public|orders` - Orders table with columns as attributes +- `postgresql|public|order_items` - Order items table with columns as attributes +- `mysql|testdb|customers` - Customers table with columns as attributes +- `mysql|testdb|inventory` - Inventory table with columns as attributes +- `mysql|testdb|transactions` - Transactions table with columns as attributes +- `mysql|testdb|transaction_items` - Transaction items table with columns as attributes + +### Columns (as separate resources) + +- Each column in each table as a separate resource with hierarchical keys + +## Cleanup + +To stop and remove the environment: + +```bash +docker-compose down -v +``` diff --git a/tests/trino/docker-compose.yml b/tests/trino/docker-compose.yml new file mode 100644 index 00000000..e7815389 --- /dev/null +++ b/tests/trino/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + # PostgreSQL database + postgres: + image: postgres:15 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + ports: + - '5432:5432' + volumes: + - ./sample_data/postgres_init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U testuser -d testdb'] + interval: 10s + timeout: 5s + retries: 5 + + # MySQL database + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: testdb + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + ports: + - '3306:3306' + volumes: + - ./sample_data/mysql_init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: + [ + 'CMD', + 'mysqladmin', + 'ping', + '-h', + 'localhost', + '-u', + 'testuser', + '-ptestpass', + ] + interval: 10s + timeout: 5s + retries: 5 + + # Trino server + trino: + image: trinodb/trino:latest + ports: + - '8080:8080' + volumes: + - ./trino_config:/etc/trino + depends_on: + postgres: + condition: service_healthy + mysql: + condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/v1/info'] + interval: 10s + timeout: 5s + retries: 5 diff --git a/tests/trino/sample_data/mysql_init.sql b/tests/trino/sample_data/mysql_init.sql new file mode 100644 index 00000000..a51aa9ab --- /dev/null +++ b/tests/trino/sample_data/mysql_init.sql @@ -0,0 +1,96 @@ +-- Create customers table +CREATE TABLE customers ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create inventory table +CREATE TABLE inventory ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + category VARCHAR(100), + stock_quantity INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create transactions table +CREATE TABLE transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_id INT, + total_amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customers(id) +); + +-- Create transaction_items table +CREATE TABLE transaction_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + transaction_id INT, + inventory_id INT, + quantity INT NOT NULL, + unit_price DECIMAL(10,2) NOT NULL, + FOREIGN KEY (transaction_id) REFERENCES transactions(id), + FOREIGN KEY (inventory_id) REFERENCES inventory(id) +); + +-- Insert sample data +INSERT INTO customers (email, first_name, last_name, is_active) VALUES +('alice.johnson@example.com', 'Alice', 'Johnson', TRUE), +('charlie.brown@example.com', 'Charlie', 'Brown', TRUE), +('diana.prince@example.com', 'Diana', 'Prince', FALSE); + +INSERT INTO inventory (name, description, price, category, stock_quantity) VALUES +('Tablet', '10-inch tablet', 299.99, 'Electronics', 30), +('Headphones', 'Noise-cancelling headphones', 199.99, 'Electronics', 60), +('Monitor', '24-inch monitor', 149.99, 'Electronics', 40), +('Desk', 'Office desk', 199.99, 'Furniture', 25); + +INSERT INTO transactions (customer_id, total_amount, status) VALUES +(1, 499.98, 'completed'), +(2, 149.99, 'pending'), +(1, 199.99, 'shipped'); + +INSERT INTO transaction_items (transaction_id, inventory_id, quantity, unit_price) VALUES +(1, 1, 1, 299.99), +(1, 2, 1, 199.99), +(2, 3, 1, 149.99), +(3, 4, 1, 199.99); + +-- View: active_customers +CREATE OR REPLACE VIEW active_customers AS +SELECT id, email, first_name, last_name +FROM customers +WHERE is_active = TRUE; + +-- Simulated Materialized View: expensive_inventory_mv +CREATE TABLE IF NOT EXISTS expensive_inventory_mv AS +SELECT id, name, price +FROM inventory +WHERE price > 200; + +-- Function: customer_full_name +DELIMITER // +CREATE FUNCTION customer_full_name(cid INT) RETURNS VARCHAR(255) +DETERMINISTIC +BEGIN + DECLARE fname VARCHAR(100); + DECLARE lname VARCHAR(100); + SELECT first_name, last_name INTO fname, lname FROM customers WHERE id = cid; + RETURN CONCAT(fname, ' ', lname); +END // +DELIMITER ; + +-- Procedure: activate_customer +DELIMITER // +CREATE PROCEDURE activate_customer(IN cid INT) +BEGIN + UPDATE customers SET is_active = TRUE WHERE id = cid; +END // +DELIMITER ; \ No newline at end of file diff --git a/tests/trino/sample_data/postgres_init.sql b/tests/trino/sample_data/postgres_init.sql new file mode 100644 index 00000000..b3c78738 --- /dev/null +++ b/tests/trino/sample_data/postgres_init.sql @@ -0,0 +1,94 @@ +-- Create users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create products table +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + price DECIMAL(10,2) NOT NULL, + category VARCHAR(100), + stock_quantity INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create orders table +CREATE TABLE orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + total_amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Create order_items table +CREATE TABLE order_items ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES orders(id), + product_id INTEGER REFERENCES products(id), + quantity INTEGER NOT NULL, + unit_price DECIMAL(10,2) NOT NULL +); + +-- Insert sample data +INSERT INTO users (email, first_name, last_name, is_active) VALUES +('john.doe@example.com', 'John', 'Doe', true), +('jane.smith@example.com', 'Jane', 'Smith', true), +('bob.wilson@example.com', 'Bob', 'Wilson', false); + +INSERT INTO products (name, description, price, category, stock_quantity) VALUES +('Laptop', 'High-performance laptop', 999.99, 'Electronics', 50), +('Mouse', 'Wireless mouse', 29.99, 'Electronics', 100), +('Keyboard', 'Mechanical keyboard', 89.99, 'Electronics', 75), +('Book', 'Programming guide', 49.99, 'Books', 200); + +INSERT INTO orders (user_id, total_amount, status) VALUES +(1, 1029.98, 'completed'), +(2, 89.99, 'pending'), +(1, 49.99, 'shipped'); + +INSERT INTO order_items (order_id, product_id, quantity, unit_price) VALUES +(1, 1, 1, 999.99), +(1, 2, 1, 29.99), +(2, 3, 1, 89.99), +(3, 4, 1, 49.99); + +-- View: active_users +CREATE OR REPLACE VIEW active_users AS +SELECT id, email, first_name, last_name +FROM users +WHERE is_active = true; + +-- Materialized View: expensive_products_mv +CREATE MATERIALIZED VIEW expensive_products_mv AS +SELECT id, name, price +FROM products +WHERE price > 500; + +-- Function: user_full_name +CREATE OR REPLACE FUNCTION user_full_name(uid integer) +RETURNS text AS $$ +DECLARE + fname text; + lname text; +BEGIN + SELECT first_name, last_name INTO fname, lname FROM users WHERE id = uid; + RETURN fname || ' ' || lname; +END; +$$ LANGUAGE plpgsql; + +-- Procedure: activate_user +CREATE OR REPLACE PROCEDURE activate_user(uid integer) +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE users SET is_active = true WHERE id = uid; +END; +$$; \ No newline at end of file diff --git a/tests/trino/trino_config/catalog/mysql.properties b/tests/trino/trino_config/catalog/mysql.properties new file mode 100644 index 00000000..90cd1000 --- /dev/null +++ b/tests/trino/trino_config/catalog/mysql.properties @@ -0,0 +1,4 @@ +connector.name=mysql +connection-url=jdbc:mysql://mysql:3306 +connection-user=testuser +connection-password=testpass \ No newline at end of file diff --git a/tests/trino/trino_config/catalog/postgresql.properties b/tests/trino/trino_config/catalog/postgresql.properties new file mode 100644 index 00000000..5ffa49bf --- /dev/null +++ b/tests/trino/trino_config/catalog/postgresql.properties @@ -0,0 +1,4 @@ +connector.name=postgresql +connection-url=jdbc:postgresql://postgres:5432/testdb +connection-user=testuser +connection-password=testpass \ No newline at end of file diff --git a/tests/trino/trino_config/config.properties b/tests/trino/trino_config/config.properties new file mode 100644 index 00000000..7de6fb8d --- /dev/null +++ b/tests/trino/trino_config/config.properties @@ -0,0 +1,4 @@ +coordinator=true +node-scheduler.include-coordinator=true +http-server.http.port=8080 +discovery.uri=http://localhost:8080 \ No newline at end of file diff --git a/tests/trino/trino_config/jvm.config b/tests/trino/trino_config/jvm.config new file mode 100644 index 00000000..5489883d --- /dev/null +++ b/tests/trino/trino_config/jvm.config @@ -0,0 +1,7 @@ +-server +-Xmx4G +-XX:+UseG1GC +-XX:G1HeapRegionSize=32M +-XX:+ExplicitGCInvokesConcurrent +-XX:+HeapDumpOnOutOfMemoryError +-XX:+ExitOnOutOfMemoryError \ No newline at end of file diff --git a/tests/trino/trino_config/node.properties b/tests/trino/trino_config/node.properties new file mode 100644 index 00000000..c8be95c9 --- /dev/null +++ b/tests/trino/trino_config/node.properties @@ -0,0 +1,3 @@ +node.environment=test +node.data-dir=/tmp/trino/data +node.id=testnode1 \ No newline at end of file diff --git a/tests/utils/trinoUtils.test.tsx b/tests/utils/trinoUtils.test.tsx new file mode 100644 index 00000000..635adba9 --- /dev/null +++ b/tests/utils/trinoUtils.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { + mapTrinoSchemaToPermitResources, + trinoTypeToPermitType, // Now exported for testing + TrinoSchemaData, +} from '../../source/utils/trinoUtils.js'; + +describe('trinoTypeToPermitType', () => { + it('maps Trino types to Permit types', () => { + expect(trinoTypeToPermitType('varchar')).toBe('string'); + expect(trinoTypeToPermitType('integer')).toBe('number'); + expect(trinoTypeToPermitType('boolean')).toBe('bool'); + expect(trinoTypeToPermitType('json')).toBe('json'); + expect(trinoTypeToPermitType('timestamp')).toBe('time'); + expect(trinoTypeToPermitType('array')).toBe('array'); + expect(trinoTypeToPermitType('row')).toBe('object'); + }); +}); + +describe('mapTrinoSchemaToPermitResources', () => { + it('maps a simple Trino schema to Permit resources', () => { + const schema: TrinoSchemaData = { + catalogs: [{ name: 'testcat' }], + schemas: [{ catalog: 'testcat', name: 'public' }], + tables: [ + { + catalog: 'testcat', + schema: 'public', + name: 'users', + type: 'BASE TABLE', + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'email', type: 'varchar', nullable: false }, + { name: 'is_active', type: 'boolean', nullable: true }, + ], + }, + ], + functions: [], + views: [], + materializedViews: [], + procedures: [], + }; + const resources = mapTrinoSchemaToPermitResources(schema); + + // Check catalogs + const catalog = resources.find(r => r.key === 'trino-catalog-testcat'); + expect(catalog).toBeDefined(); + expect(catalog?.name).toBe('testcat'); + expect(catalog?.actions).toContain('AccessCatalog'); + + // Check schemas + const schemaResource = resources.find( + r => r.key === 'trino-schema-testcat-public', + ); + expect(schemaResource).toBeDefined(); + expect(schemaResource?.name).toBe('testcat.public'); + expect(schemaResource?.actions).toContain('CreateSchema'); + + // Check tables + const table = resources.find( + r => r.key === 'trino-table-testcat-public-users', + ); + expect(table).toBeDefined(); + expect(table?.name).toBe('testcat.public.users'); + expect(table?.actions).toContain('CreateTable'); + expect(table?.attributes).toBeDefined(); + expect(table?.attributes?.id).toEqual({ type: 'number' }); + expect(table?.attributes?.email).toEqual({ type: 'string' }); + + // Check columns + const column = resources.find( + r => r.key === 'trino-column-testcat-public-users-id', + ); + expect(column).toBeDefined(); + expect(column?.name).toBe('testcat.public.users.id'); + expect(column?.actions).toContain('SelectFromColumns'); + }); + + it('maps Trino functions, views, materialized views, and procedures to Permit resources', () => { + const schema: TrinoSchemaData = { + catalogs: [{ name: 'testcat' }], + schemas: [{ catalog: 'testcat', name: 'public' }], + tables: [], + functions: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_func', + returnType: 'integer', + argumentTypes: ['varchar', 'integer'], + }, + ], + views: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_view', + columns: [ + { name: 'col1', type: 'varchar', nullable: false }, + { name: 'col2', type: 'integer', nullable: true }, + ], + }, + ], + materializedViews: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_mview', + columns: [{ name: 'total', type: 'decimal', nullable: false }], + }, + ], + procedures: [ + { + catalog: 'testcat', + schema: 'public', + name: 'my_proc', + argumentTypes: ['varchar'], + }, + ], + }; + + const resources = mapTrinoSchemaToPermitResources(schema); + + // Check function + const func = resources.find( + r => r.key === 'trino-function-testcat-public-my_func', + ); + expect(func).toBeDefined(); + expect(func?.name).toBe('testcat.public.my_func'); + expect(func?.actions).toContain('ExecuteFunction'); + expect(func?.actions).toContain('ShowFunctions'); + expect(func?.attributes?.returnType).toEqual({ type: 'number' }); + expect(func?.attributes?.argumentTypes).toEqual({ type: 'array' }); + + // Check view + const view = resources.find( + r => r.key === 'trino-view-testcat-public-my_view', + ); + expect(view).toBeDefined(); + expect(view?.name).toBe('testcat.public.my_view'); + expect(view?.actions).toContain('CreateView'); + expect(view?.actions).toContain('DropView'); + expect(view?.attributes?.col1).toEqual({ type: 'string' }); + expect(view?.attributes?.col2).toEqual({ + type: 'number', + description: 'nullable', + }); + + // Check materialized view + const mview = resources.find( + r => r.key === 'trino-materialized_view-testcat-public-my_mview', + ); + expect(mview).toBeDefined(); + expect(mview?.name).toBe('testcat.public.my_mview'); + expect(mview?.actions).toContain('CreateMaterializedView'); + expect(mview?.actions).toContain('RefreshMaterializedView'); + expect(mview?.attributes?.total).toEqual({ type: 'number' }); + + // Check procedure + const proc = resources.find( + r => r.key === 'trino-procedure-testcat-public-my_proc', + ); + expect(proc).toBeDefined(); + expect(proc?.name).toBe('testcat.public.my_proc'); + expect(proc?.actions).toContain('ExecuteProcedure'); + expect(proc?.attributes?.argumentTypes).toEqual({ type: 'array' }); + }); +});