Skip to content

Commit 171f90d

Browse files
authored
[TILES-V2-3]: implement postgres functions for row, column and table operations (#974)
### Changes Added PostgreSQL support for Tiles with streaming capabilities and ULID for row IDs. ### What changed? - Added new dependencies: `pg-query-stream` and `ulid` to support PostgreSQL streaming and monotonic ID generation - Created new PostgreSQL-specific implementation files for table operations: - `table-functions.ts` - For creating tables - `table-column-functions.ts` - For managing table columns - `table-row-functions.ts` - For CRUD operations on table rows - Implemented types for table operations in `types.ts` - Introduced ULID (Universally Unique Lexicographically Sortable Identifier) for row IDs ### How to test? 1. Install the new dependencies with `npm install` 2. Test the PostgreSQL implementation by running the existing integration tests
1 parent 89e5ec6 commit 171f90d

File tree

8 files changed

+406
-0
lines changed

8 files changed

+406
-0
lines changed

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@
7474
"objection": "^3.0.0",
7575
"p-limit": "3.1.0",
7676
"pg": "^8.7.1",
77+
"pg-query-stream": "4.9.6",
7778
"rate-limiter-flexible": "2.4.1",
7879
"remark-breaks": "3.0.3",
7980
"remark-gfm": "3.0.1",
8081
"typescript": "^4.6.3",
82+
"ulid": "3.0.0",
8183
"urlcat": "3.1.0",
8284
"winston": "^3.7.1",
8385
"zod": "3.22.4",

packages/backend/src/graphql/__tests__/queries/tiles/get-all-rows.itest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ describe('get all rows query', () => {
122122
data[randomUUID()] = 'test'
123123
const rowToInsert = {
124124
tableId: dummyTable.id,
125+
// TODO: use ulid for new tiles
125126
rowId: randomUUID(),
126127
data,
127128
}

packages/backend/src/graphql/__tests__/queries/tiles/table-row.mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export async function insertMockTableRows(
1313
for (let i = 0; i < numRowsToInsert; i++) {
1414
rows.push({
1515
tableId,
16+
// use ulid for new tiles
1617
rowId: randomUUID(),
1718
data: generateMockTableRowData({ columnIds }),
1819
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { tilesClient } from '@/config/tiles-database'
2+
3+
export function createTableColumns(tableId: string, columnIds: string[]) {
4+
return tilesClient.schema.alterTable(tableId, (table) => {
5+
columnIds.forEach((columnId) => {
6+
table.text(columnId)
7+
})
8+
})
9+
}
10+
11+
export function deleteTableColumns(tableId: string, columnIds: string[]) {
12+
return tilesClient.schema.alterTable(tableId, (table) => {
13+
columnIds.forEach((columnId) => {
14+
table.dropColumn(columnId)
15+
})
16+
})
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { tilesClient } from '@/config/tiles-database'
2+
3+
export function createTable(tableId: string, columnIds: string[]) {
4+
return tilesClient.schema.createTable(tableId, (table) => {
5+
table.string('rowId').primary()
6+
columnIds.forEach((columnId) => {
7+
table.string(columnId)
8+
})
9+
table.timestamps(true, true, true)
10+
})
11+
}
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { Knex } from 'knex'
2+
import { monotonicFactory, ulid } from 'ulid'
3+
4+
import { tilesClient } from '@/config/tiles-database'
5+
import logger from '@/helpers/logger'
6+
7+
import {
8+
CreateRowInput,
9+
CreateRowsInput,
10+
DeleteRowsInput,
11+
PatchRowInput,
12+
TableRowFilter,
13+
TableRowFilterOperator,
14+
TableRowItem,
15+
TableRowOutput,
16+
UpdateRowInput,
17+
} from '../types'
18+
19+
/**
20+
* External functions
21+
*/
22+
23+
export const createTableRow = async ({
24+
tableId,
25+
data,
26+
}: CreateRowInput): Promise<TableRowItem> => {
27+
const ulid = monotonicFactory()
28+
try {
29+
const res = await tilesClient(tableId)
30+
.insert({
31+
...data,
32+
rowId: ulid(),
33+
})
34+
.returning('*')
35+
return res[0]
36+
} catch (e: unknown) {
37+
logger.error(e)
38+
throw e
39+
}
40+
}
41+
42+
export const createTableRows = async ({
43+
tableId,
44+
dataArray,
45+
}: CreateRowsInput): Promise<string[]> => {
46+
try {
47+
const rows = dataArray.map((data, i) => ({
48+
rowId: ulid(),
49+
...data,
50+
// manually bumping the createdAt timestamp to ensure that row order is preserved
51+
createdAt: new Date(Date.now() + i),
52+
}))
53+
const res = await tilesClient(tableId).insert(rows).returning(['rowId'])
54+
return res.map((row) => row.rowId)
55+
} catch (e: unknown) {
56+
logger.error(e)
57+
throw e
58+
}
59+
}
60+
61+
/**
62+
* This replaces the entire data object for the row
63+
*/
64+
export const updateTableRow = async ({
65+
rowId,
66+
tableId,
67+
data,
68+
}: UpdateRowInput): Promise<void> => {
69+
try {
70+
await tilesClient(tableId)
71+
.where({
72+
rowId,
73+
})
74+
.update(data)
75+
.update('updatedAt', new Date())
76+
} catch (e: unknown) {
77+
logger.error(e)
78+
throw e
79+
}
80+
}
81+
82+
/**
83+
* This atomically updates the data object for keys that are changed
84+
*/
85+
export const patchTableRow = async ({
86+
rowId,
87+
tableId,
88+
patchData,
89+
}: PatchRowInput): Promise<TableRowItem> => {
90+
try {
91+
const query = tilesClient(tableId).where({ rowId })
92+
93+
Object.entries(patchData.set || {}).forEach(
94+
([key, value]: [string, string]) => {
95+
query.update(key, value)
96+
},
97+
)
98+
99+
Object.entries(patchData.add || {}).forEach(
100+
([key, value]: [string, string]) => {
101+
if (isNaN(+value)) {
102+
throw new Error(`Invalid value for add operation: ${value}`)
103+
}
104+
query
105+
.update(
106+
key,
107+
tilesClient.raw('(CAST(?? AS double precision) + ?)::text', [
108+
key,
109+
+value,
110+
]),
111+
)
112+
.where(key, '~', '^[-+]?\\d*\\.?\\d+$')
113+
},
114+
)
115+
116+
Object.entries(patchData.subtract || {}).forEach(
117+
([key, value]: [string, string]) => {
118+
if (isNaN(+value)) {
119+
throw new Error(`Invalid value for subtract operation: ${value}`)
120+
}
121+
query
122+
.update(
123+
key,
124+
tilesClient.raw('(CAST(?? AS double precision) - ?)::text', [
125+
key,
126+
+value,
127+
]),
128+
)
129+
.where(key, '~', '^[-+]?\\d*\\.?\\d+$')
130+
},
131+
)
132+
133+
const res = await query.update('updatedAt', new Date()).returning('*')
134+
return res[0]
135+
} catch (e: unknown) {
136+
logger.error(e)
137+
throw e
138+
}
139+
}
140+
141+
export const deleteTableRows = async ({
142+
rowIds,
143+
tableId,
144+
}: DeleteRowsInput): Promise<void> => {
145+
try {
146+
await tilesClient.into(tableId).whereIn('rowId', rowIds).delete()
147+
return
148+
} catch (e: unknown) {
149+
logger.error(e)
150+
throw e
151+
}
152+
}
153+
154+
export const getTableRowCount = async ({
155+
tableId,
156+
}: {
157+
tableId: string
158+
}): Promise<number> => {
159+
try {
160+
const res = await tilesClient(tableId).count({ count: '*' })
161+
return res[0].count
162+
} catch (e: unknown) {
163+
logger.error(e)
164+
throw e
165+
}
166+
}
167+
168+
function addFiltersToQuery(
169+
query: Knex.QueryBuilder,
170+
filters: TableRowFilter[],
171+
) {
172+
for (const filter of filters) {
173+
switch (filter.operator) {
174+
case TableRowFilterOperator.Equals:
175+
query.where(filter.columnId, '=', filter.value)
176+
break
177+
case TableRowFilterOperator.Contains:
178+
query.where(filter.columnId, 'ilike', `%${filter.value}%`)
179+
break
180+
case TableRowFilterOperator.GreaterThan:
181+
query.where(filter.columnId, '>', filter.value)
182+
break
183+
case TableRowFilterOperator.GreaterThanOrEquals:
184+
query.where(filter.columnId, '>=', filter.value)
185+
break
186+
case TableRowFilterOperator.LessThan:
187+
query.where(filter.columnId, '<', filter.value)
188+
break
189+
case TableRowFilterOperator.LessThanOrEquals:
190+
query.where(filter.columnId, '<=', filter.value)
191+
break
192+
case TableRowFilterOperator.IsEmpty:
193+
query.where((builder) => {
194+
builder.whereNull(filter.columnId).orWhere(filter.columnId, '')
195+
})
196+
break
197+
case TableRowFilterOperator.BeginsWith:
198+
query.where(filter.columnId, 'ilike', `${filter.value}%`)
199+
break
200+
default:
201+
throw new Error(`Unsupported filter operator: ${filter.operator}`)
202+
}
203+
}
204+
}
205+
206+
export const getTableRows = async ({
207+
tableId,
208+
columnIds,
209+
filters,
210+
order = 'asc',
211+
scanLimit,
212+
}: {
213+
tableId: string
214+
columnIds?: string[]
215+
filters?: TableRowFilter[]
216+
order?: 'asc' | 'desc'
217+
/**
218+
* Optional limit on the total number of rows scanned.
219+
*/
220+
scanLimit?: number
221+
}): Promise<{
222+
rows: TableRowOutput[]
223+
}> => {
224+
const query = tilesClient(tableId).select(
225+
columnIds ? ['rowId', ...columnIds] : ['*'],
226+
)
227+
if (filters) {
228+
addFiltersToQuery(query, filters)
229+
}
230+
if (scanLimit) {
231+
query.limit(scanLimit)
232+
}
233+
try {
234+
const tableRows = []
235+
const stream = query.orderBy('rowId', order).stream()
236+
for await (const row of stream) {
237+
const { rowId, ...rest } = row
238+
tableRows.push({ rowId, data: rest })
239+
}
240+
return {
241+
rows: tableRows,
242+
}
243+
} catch (e: unknown) {
244+
logger.error(e)
245+
throw e
246+
}
247+
}
248+
249+
/**
250+
* Column IDs are unmapped
251+
*/
252+
export const getRawRowById = async ({
253+
tableId,
254+
rowId,
255+
columnIds,
256+
}: {
257+
tableId: string
258+
rowId: string
259+
columnIds?: string[]
260+
}): Promise<TableRowOutput | null> => {
261+
try {
262+
const res = await tilesClient(tableId)
263+
.where({
264+
rowId,
265+
})
266+
.select(columnIds ? ['rowId', ...columnIds] : ['*'])
267+
.first()
268+
return res
269+
} catch (e: unknown) {
270+
logger.error(e)
271+
throw e
272+
}
273+
}

0 commit comments

Comments
 (0)