Skip to content

Commit fc6e4bd

Browse files
committed
feat: materialized views
1 parent e4dc217 commit fc6e4bd

File tree

9 files changed

+242
-1
lines changed

9 files changed

+242
-1
lines changed

src/lib/PostgresMeta.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import PostgresMetaConfig from './PostgresMetaConfig.js'
55
import PostgresMetaExtensions from './PostgresMetaExtensions.js'
66
import PostgresMetaForeignTables from './PostgresMetaForeignTables.js'
77
import PostgresMetaFunctions from './PostgresMetaFunctions.js'
8+
import PostgresMetaMaterializedViews from './PostgresMetaMaterializedViews.js'
89
import PostgresMetaPolicies from './PostgresMetaPolicies.js'
910
import PostgresMetaPublications from './PostgresMetaPublications.js'
1011
import PostgresMetaRoles from './PostgresMetaRoles.js'
@@ -25,6 +26,7 @@ export default class PostgresMeta {
2526
extensions: PostgresMetaExtensions
2627
foreignTables: PostgresMetaForeignTables
2728
functions: PostgresMetaFunctions
29+
materializedViews: PostgresMetaMaterializedViews
2830
policies: PostgresMetaPolicies
2931
publications: PostgresMetaPublications
3032
roles: PostgresMetaRoles
@@ -48,6 +50,7 @@ export default class PostgresMeta {
4850
this.extensions = new PostgresMetaExtensions(this.query)
4951
this.foreignTables = new PostgresMetaForeignTables(this.query)
5052
this.functions = new PostgresMetaFunctions(this.query)
53+
this.materializedViews = new PostgresMetaMaterializedViews(this.query)
5154
this.policies = new PostgresMetaPolicies(this.query)
5255
this.publications = new PostgresMetaPublications(this.query)
5356
this.roles = new PostgresMetaRoles(this.query)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { literal } from 'pg-format'
2+
import { coalesceRowsToArray, filterByList } from './helpers.js'
3+
import { columnsSql, materializedViewsSql } from './sql/index.js'
4+
import { PostgresMetaResult, PostgresMaterializedView } from './types.js'
5+
6+
export default class PostgresMetaMaterializedViews {
7+
query: (sql: string) => Promise<PostgresMetaResult<any>>
8+
9+
constructor(query: (sql: string) => Promise<PostgresMetaResult<any>>) {
10+
this.query = query
11+
}
12+
13+
async list(options: {
14+
includedSchemas?: string[]
15+
excludedSchemas?: string[]
16+
limit?: number
17+
offset?: number
18+
includeColumns: true
19+
}): Promise<PostgresMetaResult<(PostgresMaterializedView & { columns: unknown[] })[]>>
20+
async list(options?: {
21+
includedSchemas?: string[]
22+
excludedSchemas?: string[]
23+
limit?: number
24+
offset?: number
25+
includeColumns?: boolean
26+
}): Promise<PostgresMetaResult<(PostgresMaterializedView & { columns: never })[]>>
27+
async list({
28+
includedSchemas,
29+
excludedSchemas,
30+
limit,
31+
offset,
32+
includeColumns = false,
33+
}: {
34+
includedSchemas?: string[]
35+
excludedSchemas?: string[]
36+
limit?: number
37+
offset?: number
38+
includeColumns?: boolean
39+
} = {}): Promise<PostgresMetaResult<PostgresMaterializedView[]>> {
40+
let sql = generateEnrichedMaterializedViewsSql({ includeColumns })
41+
const filter = filterByList(includedSchemas, excludedSchemas, undefined)
42+
if (filter) {
43+
sql += ` where schema ${filter}`
44+
}
45+
if (limit) {
46+
sql += ` limit ${limit}`
47+
}
48+
if (offset) {
49+
sql += ` offset ${offset}`
50+
}
51+
return await this.query(sql)
52+
}
53+
54+
async retrieve({ id }: { id: number }): Promise<PostgresMetaResult<PostgresMaterializedView>>
55+
async retrieve({
56+
name,
57+
schema,
58+
}: {
59+
name: string
60+
schema: string
61+
}): Promise<PostgresMetaResult<PostgresMaterializedView>>
62+
async retrieve({
63+
id,
64+
name,
65+
schema = 'public',
66+
}: {
67+
id?: number
68+
name?: string
69+
schema?: string
70+
}): Promise<PostgresMetaResult<PostgresMaterializedView>> {
71+
if (id) {
72+
const sql = `${generateEnrichedMaterializedViewsSql({
73+
includeColumns: true,
74+
})} where materialized_views.id = ${literal(id)};`
75+
console.log(sql)
76+
const { data, error } = await this.query(sql)
77+
if (error) {
78+
return { data, error }
79+
} else if (data.length === 0) {
80+
return { data: null, error: { message: `Cannot find a materialized view with ID ${id}` } }
81+
} else {
82+
return { data: data[0], error }
83+
}
84+
} else if (name) {
85+
const sql = `${generateEnrichedMaterializedViewsSql({
86+
includeColumns: true,
87+
})} where materialized_views.name = ${literal(
88+
name
89+
)} and materialized_views.schema = ${literal(schema)};`
90+
const { data, error } = await this.query(sql)
91+
if (error) {
92+
return { data, error }
93+
} else if (data.length === 0) {
94+
return {
95+
data: null,
96+
error: { message: `Cannot find a materialized view named ${name} in schema ${schema}` },
97+
}
98+
} else {
99+
return { data: data[0], error }
100+
}
101+
} else {
102+
return { data: null, error: { message: 'Invalid parameters on materialized view retrieve' } }
103+
}
104+
}
105+
}
106+
107+
const generateEnrichedMaterializedViewsSql = ({ includeColumns }: { includeColumns: boolean }) => `
108+
with materialized_views as (${materializedViewsSql})
109+
${includeColumns ? `, columns as (${columnsSql})` : ''}
110+
select
111+
*
112+
${
113+
includeColumns
114+
? `, ${coalesceRowsToArray('columns', 'columns.table_id = materialized_views.id')}`
115+
: ''
116+
}
117+
from materialized_views`

src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {
88
PostgresExtension,
99
PostgresFunction,
1010
PostgresFunctionCreate,
11+
PostgresMaterializedView,
1112
PostgresPolicy,
1213
PostgresPrimaryKey,
1314
PostgresPublication,

src/lib/sql/columns.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ WHERE
8585
NOT pg_is_other_temp_schema(nc.oid)
8686
AND a.attnum > 0
8787
AND NOT a.attisdropped
88-
AND (c.relkind IN ('r', 'v', 'f', 'p'))
88+
AND (c.relkind IN ('r', 'v', 'm', 'f', 'p'))
8989
AND (
9090
pg_has_role(c.relowner, 'USAGE')
9191
OR has_column_privilege(

src/lib/sql/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const configSql = await readFile(join(__dirname, 'config.sql'), 'utf-8')
88
export const extensionsSql = await readFile(join(__dirname, 'extensions.sql'), 'utf-8')
99
export const foreignTablesSql = await readFile(join(__dirname, 'foreign_tables.sql'), 'utf-8')
1010
export const functionsSql = await readFile(join(__dirname, 'functions.sql'), 'utf-8')
11+
export const materializedViewsSql = await readFile(
12+
join(__dirname, 'materialized_views.sql'),
13+
'utf-8'
14+
)
1115
export const policiesSql = await readFile(join(__dirname, 'policies.sql'), 'utf-8')
1216
export const primaryKeysSql = await readFile(join(__dirname, 'primary_keys.sql'), 'utf-8')
1317
export const publicationsSql = await readFile(join(__dirname, 'publications.sql'), 'utf-8')

src/lib/sql/materialized_views.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
select
2+
c.oid::int8 as id,
3+
n.nspname as schema,
4+
c.relname as name,
5+
c.relispopulated as is_populated,
6+
obj_description(c.oid) as comment
7+
from
8+
pg_class c
9+
join pg_namespace n on n.oid = c.relnamespace
10+
where
11+
c.relkind = 'm'

src/lib/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,13 @@ export const postgresViewSchema = Type.Object({
384384
columns: Type.Optional(Type.Array(postgresColumnSchema)),
385385
})
386386
export type PostgresView = Static<typeof postgresViewSchema>
387+
388+
export const postgresMaterializedViewSchema = Type.Object({
389+
id: Type.Integer(),
390+
schema: Type.String(),
391+
name: Type.String(),
392+
is_populated: Type.Boolean(),
393+
comment: Type.Union([Type.String(), Type.Null()]),
394+
columns: Type.Optional(Type.Array(postgresColumnSchema)),
395+
})
396+
export type PostgresMaterializedView = Static<typeof postgresMaterializedViewSchema>

src/server/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ConfigRoute from './config.js'
55
import ExtensionsRoute from './extensions.js'
66
import ForeignTablesRoute from './foreign-tables.js'
77
import FunctionsRoute from './functions.js'
8+
import MaterializedViewsRoute from './materialized-views.js'
89
import PoliciesRoute from './policies.js'
910
import PublicationsRoute from './publications.js'
1011
import QueryRoute from './query.js'
@@ -45,6 +46,7 @@ export default async (fastify: FastifyInstance) => {
4546
fastify.register(ExtensionsRoute, { prefix: '/extensions' })
4647
fastify.register(ForeignTablesRoute, { prefix: '/foreign-tables' })
4748
fastify.register(FunctionsRoute, { prefix: '/functions' })
49+
fastify.register(MaterializedViewsRoute, { prefix: '/materialized-views' })
4850
fastify.register(PoliciesRoute, { prefix: '/policies' })
4951
fastify.register(PublicationsRoute, { prefix: '/publications' })
5052
fastify.register(QueryRoute, { prefix: '/query' })
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
2+
import { Type } from '@sinclair/typebox'
3+
import { PostgresMeta } from '../../lib/index.js'
4+
import { postgresMaterializedViewSchema } from '../../lib/types.js'
5+
import { DEFAULT_POOL_CONFIG } from '../constants.js'
6+
import { extractRequestForLogging } from '../utils.js'
7+
8+
const route: FastifyPluginAsyncTypebox = async (fastify) => {
9+
fastify.get(
10+
'/',
11+
{
12+
schema: {
13+
headers: Type.Object({
14+
pg: Type.String(),
15+
}),
16+
querystring: Type.Object({
17+
included_schemas: Type.Optional(Type.String()),
18+
excluded_schemas: Type.Optional(Type.String()),
19+
limit: Type.Optional(Type.Integer()),
20+
offset: Type.Optional(Type.Integer()),
21+
include_columns: Type.Optional(Type.Boolean()),
22+
}),
23+
response: {
24+
200: Type.Array(postgresMaterializedViewSchema),
25+
500: Type.Object({
26+
error: Type.String(),
27+
}),
28+
},
29+
},
30+
},
31+
async (request, reply) => {
32+
const connectionString = request.headers.pg
33+
const includedSchemas = request.query.included_schemas?.split(',')
34+
const excludedSchemas = request.query.excluded_schemas?.split(',')
35+
const limit = request.query.limit
36+
const offset = request.query.offset
37+
const includeColumns = request.query.include_columns
38+
39+
const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
40+
const { data, error } = await pgMeta.materializedViews.list({
41+
includedSchemas,
42+
excludedSchemas,
43+
limit,
44+
offset,
45+
includeColumns,
46+
})
47+
await pgMeta.end()
48+
if (error) {
49+
request.log.error({ error, request: extractRequestForLogging(request) })
50+
reply.code(500)
51+
return { error: error.message }
52+
}
53+
54+
return data
55+
}
56+
)
57+
58+
fastify.get(
59+
'/:id(\\d+)',
60+
{
61+
schema: {
62+
headers: Type.Object({
63+
pg: Type.String(),
64+
}),
65+
params: Type.Object({
66+
id: Type.Integer(),
67+
}),
68+
response: {
69+
200: postgresMaterializedViewSchema,
70+
404: Type.Object({
71+
error: Type.String(),
72+
}),
73+
},
74+
},
75+
},
76+
async (request, reply) => {
77+
const connectionString = request.headers.pg
78+
const id = request.params.id
79+
80+
const pgMeta = new PostgresMeta({ ...DEFAULT_POOL_CONFIG, connectionString })
81+
const { data, error } = await pgMeta.materializedViews.retrieve({ id })
82+
await pgMeta.end()
83+
if (error) {
84+
request.log.error({ error, request: extractRequestForLogging(request) })
85+
reply.code(404)
86+
return { error: error.message }
87+
}
88+
89+
return data
90+
}
91+
)
92+
}
93+
export default route

0 commit comments

Comments
 (0)