Skip to content

Commit 400b9af

Browse files
committed
Support schema in LiteREST for external connections
1 parent 635f7ed commit 400b9af

File tree

6 files changed

+119
-90
lines changed

6 files changed

+119
-90
lines changed

src/handler.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import { importTableFromCsvRoute } from "./import/csv";
1414
export class Handler {
1515
private liteREST?: LiteREST;
1616
private dataSource?: DataSource;
17+
private env?: Env;
1718

1819
constructor() { }
1920

2021
public async handle(request: Request, dataSource: DataSource, env: Env): Promise<Response> {
2122
this.dataSource = dataSource;
2223
this.liteREST = new LiteREST(dataSource, env);
24+
this.env = env;
2325
const url = new URL(request.url);
2426

2527
if (request.method === 'POST' && url.pathname === '/query/raw') {
@@ -76,7 +78,7 @@ export class Handler {
7678
if (this.dataSource.source === Source.external) {
7779
return createResponse(undefined, 'Function is only available for internal data source.', 400);
7880
}
79-
81+
8082
const tableName = url.pathname.split('/').pop();
8183
if (!tableName) {
8284
return createResponse(undefined, 'Table name is required', 400);
@@ -111,15 +113,15 @@ export class Handler {
111113
return { sql, params };
112114
});
113115

114-
const response = await executeTransaction(queries, isRaw, this.dataSource);
116+
const response = await executeTransaction(queries, isRaw, this.dataSource, this.env);
115117
return createResponse(response, undefined, 200);
116118
} else if (typeof sql !== 'string' || !sql.trim()) {
117119
return createResponse(undefined, 'Invalid or empty "sql" field.', 400);
118120
} else if (params !== undefined && !Array.isArray(params) && typeof params !== 'object') {
119121
return createResponse(undefined, 'Invalid "params" field. Must be an array or object.', 400);
120122
}
121123

122-
const response = await executeQuery(sql, params, isRaw, this.dataSource);
124+
const response = await executeQuery(sql, params, isRaw, this.dataSource, this.env);
123125
return createResponse(response, undefined, 200);
124126
} catch (error: any) {
125127
console.error('Query Route Error:', error);

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export default {
123123
},
124124
externalConnection: {
125125
outerbaseApiKey: env.OUTERBASE_API_KEY ?? ''
126-
},
126+
}
127127
};
128128

129129
const response = await new Handler().handle(request, dataSource, env);

src/literest/index.ts

Lines changed: 45 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,34 @@ export class LiteREST {
2929
* @param tableName - The name of the table.
3030
* @returns An array of primary key column names.
3131
*/
32-
private async getPrimaryKeyColumns(tableName: string): Promise<string[]> {
32+
private async getPrimaryKeyColumns(tableName: string, schemaName?: string): Promise<string[]> {
3333
let query = `PRAGMA table_info(${tableName});`;
34-
34+
3535
if (this.dataSource.source === Source.external) {
3636
if (this.env.EXTERNAL_DB_TYPE?.toLowerCase() === "postgres") {
37-
query = `SELECT * FROM information_schema.table_constraints WHERE table_name = '${tableName}' AND constraint_type = 'PRIMARY KEY';`;
37+
query = `
38+
SELECT kcu.column_name AS name
39+
FROM information_schema.table_constraints tc
40+
JOIN information_schema.key_column_usage kcu
41+
ON tc.constraint_name = kcu.constraint_name
42+
AND tc.table_schema = kcu.table_schema
43+
AND tc.table_name = kcu.table_name
44+
WHERE tc.constraint_type = 'PRIMARY KEY'
45+
AND tc.table_name = '${tableName}'
46+
AND tc.table_schema = '${schemaName ?? 'public'}';`;
3847
} else if (this.env.EXTERNAL_DB_TYPE?.toLowerCase() === "mysql") {
39-
query = `SELECT COLUMN_NAME FROM information_schema.key_column_usage
40-
WHERE table_name = '${tableName}'
41-
AND constraint_name = 'PRIMARY'
42-
AND table_schema = DATABASE();`;
48+
query = `
49+
SELECT COLUMN_NAME AS name FROM information_schema.key_column_usage
50+
WHERE table_name = '${tableName}'
51+
AND constraint_name = 'PRIMARY'
52+
AND table_schema = ${schemaName ?? 'DATABASE()'};
53+
`;
4354
}
4455
}
4556

46-
const schemaInfo = (await executeQuery(query, [], false, this.dataSource)) as any[];
57+
const schemaInfo = (await executeQuery(query, this.dataSource.source === Source.external ? {} : [], false, this.dataSource, this.env)) as any[];
4758
const pkColumns = schemaInfo
48-
.filter(col => typeof col.pk === 'number' && col.pk > 0 && col.name !== null)
59+
// .filter(col => typeof col.pk === 'number' && col.pk > 0 && col.name !== null)
4960
.map(col => col.name as string);
5061
return pkColumns;
5162
}
@@ -90,7 +101,7 @@ export class LiteREST {
90101
const conditions: string[] = [];
91102
const params: any[] = [];
92103

93-
if (pkColumns.length === 1) {
104+
if (pkColumns?.length === 1) {
94105
const pk = pkColumns[0];
95106
const pkValue = id || data[pk] || searchParams.get(pk);
96107
if (!pkValue) {
@@ -118,7 +129,7 @@ export class LiteREST {
118129
* @param queries - The operations to execute.
119130
*/
120131
private async executeOperation(queries: { sql: string, params: any[] }[]): Promise<{ result?: any, error?: string | undefined, status: number }> {
121-
const results: any[] = (await executeTransaction(queries, false, this.dataSource)) as any[];
132+
const results: any[] = (await executeTransaction(queries, false, this.dataSource, this.env)) as any[];
122133
return { result: results?.length > 0 ? results[0] : undefined, status: 200 };
123134
}
124135

@@ -128,20 +139,20 @@ export class LiteREST {
128139
* @returns The response to the request.
129140
*/
130141
async handleRequest(request: Request): Promise<Response> {
131-
const { method, tableName, id, searchParams, body } = await this.parseRequest(request);
142+
const { method, tableName, schemaName, id, searchParams, body } = await this.parseRequest(request);
132143

133144
try {
134145
switch (method) {
135146
case 'GET':
136-
return await this.handleGet(tableName, id, searchParams);
147+
return await this.handleGet(tableName, schemaName, id, searchParams);
137148
case 'POST':
138-
return await this.handlePost(tableName, body);
149+
return await this.handlePost(tableName, schemaName, body);
139150
case 'PATCH':
140-
return await this.handlePatch(tableName, id, body);
151+
return await this.handlePatch(tableName, schemaName, id, body);
141152
case 'PUT':
142-
return await this.handlePut(tableName, id, body);
153+
return await this.handlePut(tableName, schemaName, id, body);
143154
case 'DELETE':
144-
return await this.handleDelete(tableName, id);
155+
return await this.handleDelete(tableName, schemaName, id);
145156
default:
146157
return createResponse(undefined, 'Method not allowed', 405);
147158
}
@@ -156,7 +167,7 @@ export class LiteREST {
156167
* @param request - The incoming request.
157168
* @returns An object containing the method, table name, id, search parameters, and body.
158169
*/
159-
private async parseRequest(request: Request): Promise<{ method: string, tableName: string, id?: string, searchParams: URLSearchParams, body?: any }> {
170+
private async parseRequest(request: Request): Promise<{ method: string, tableName: string, schemaName: string | undefined, id?: string, searchParams: URLSearchParams, body?: any }> {
160171
const liteRequest = new Request(request.url.replace('/rest', ''), request);
161172
const url = new URL(liteRequest.url);
162173
const pathParts = url.pathname.split('/').filter(Boolean);
@@ -165,24 +176,26 @@ export class LiteREST {
165176
throw new Error('Invalid route');
166177
}
167178

168-
const tableName = this.sanitizeIdentifier(pathParts[0]);
169-
const id = pathParts[1];
179+
const tableName = this.sanitizeIdentifier(pathParts.length === 1 ? pathParts[0] : pathParts[1]);
180+
const schemaName = pathParts.length === 1 ? undefined : this.sanitizeIdentifier(pathParts[0])
181+
const id = pathParts.length === 1 ? pathParts[1] : pathParts[2];
170182
const body = ['POST', 'PUT', 'PATCH'].includes(liteRequest.method) ? await liteRequest.json() : undefined;
171183

172184
return {
173185
method: liteRequest.method,
174186
tableName,
187+
schemaName,
175188
id,
176189
searchParams: url.searchParams,
177190
body
178191
};
179192
}
180193

181-
private async buildSelectQuery(tableName: string, id: string | undefined, searchParams: URLSearchParams): Promise<{ query: string, params: any[] }> {
182-
let query = `SELECT * FROM ${tableName}`;
194+
private async buildSelectQuery(tableName: string, schemaName: string | undefined, id: string | undefined, searchParams: URLSearchParams): Promise<{ query: string, params: any[] }> {
195+
let query = `SELECT * FROM ${schemaName ? `${schemaName}.` : ''}${tableName}`;
183196
const params: any[] = [];
184197
const conditions: string[] = [];
185-
const pkColumns = await this.getPrimaryKeyColumns(tableName);
198+
const pkColumns = await this.getPrimaryKeyColumns(tableName, schemaName);
186199
const { conditions: pkConditions, params: pkParams, error } = this.getPrimaryKeyConditions(pkColumns, id, {}, searchParams);
187200

188201
if (!error) {
@@ -249,8 +262,8 @@ export class LiteREST {
249262
return { query, params };
250263
}
251264

252-
private async handleGet(tableName: string, id: string | undefined, searchParams: URLSearchParams): Promise<Response> {
253-
const { query, params } = await this.buildSelectQuery(tableName, id, searchParams);
265+
private async handleGet(tableName: string, schemaName: string | undefined, id: string | undefined, searchParams: URLSearchParams): Promise<Response> {
266+
const { query, params } = await this.buildSelectQuery(tableName, schemaName, id, searchParams);
254267

255268
try {
256269
const response = await this.executeOperation([{ sql: query, params }]);
@@ -262,7 +275,7 @@ export class LiteREST {
262275
}
263276
}
264277

265-
private async handlePost(tableName: string, data: any): Promise<Response> {
278+
private async handlePost(tableName: string, schemaName: string | undefined, data: any): Promise<Response> {
266279
if (!this.isDataValid(data)) {
267280
console.error('Invalid data format for POST:', data);
268281
return createResponse(undefined, 'Invalid data format', 400);
@@ -293,8 +306,8 @@ export class LiteREST {
293306
}
294307
}
295308

296-
private async handlePatch(tableName: string, id: string | undefined, data: any): Promise<Response> {
297-
const pkColumns = await this.getPrimaryKeyColumns(tableName);
309+
private async handlePatch(tableName: string, schemaName: string | undefined, id: string | undefined, data: any): Promise<Response> {
310+
const pkColumns = await this.getPrimaryKeyColumns(tableName, schemaName);
298311

299312
const { conditions: pkConditions, params: pkParams, error } = this.getPrimaryKeyConditions(pkColumns, id, data, new URLSearchParams());
300313

@@ -342,8 +355,8 @@ export class LiteREST {
342355
}
343356
}
344357

345-
private async handlePut(tableName: string, id: string | undefined, data: any): Promise<Response> {
346-
const pkColumns = await this.getPrimaryKeyColumns(tableName);
358+
private async handlePut(tableName: string, schemaName: string | undefined, id: string | undefined, data: any): Promise<Response> {
359+
const pkColumns = await this.getPrimaryKeyColumns(tableName, schemaName);
347360

348361
const { conditions: pkConditions, params: pkParams, error } = this.getPrimaryKeyConditions(pkColumns, id, data, new URLSearchParams());
349362

@@ -383,8 +396,8 @@ export class LiteREST {
383396
}
384397
}
385398

386-
private async handleDelete(tableName: string, id: string | undefined): Promise<Response> {
387-
const pkColumns = await this.getPrimaryKeyColumns(tableName);
399+
private async handleDelete(tableName: string, schemaName: string | undefined, id: string | undefined): Promise<Response> {
400+
const pkColumns = await this.getPrimaryKeyColumns(tableName, schemaName);
388401

389402
const { conditions: pkConditions, params: pkParams, error } = this.getPrimaryKeyConditions(pkColumns, id, {}, new URLSearchParams());
390403

src/operation.ts

Lines changed: 66 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DataSource } from '.';
2+
import { Env } from './'
23

34
export type OperationQueueItem = {
45
queries: { sql: string; params?: any[] }[];
@@ -19,82 +20,97 @@ export type RawQueryResponse = {
1920

2021
export type QueryResponse = any[] | RawQueryResponse;
2122

22-
async function afterQuery(sql: string, result: any, isRaw: boolean, dataSource?: DataSource): Promise<any> {
23+
async function afterQuery(sql: string, result: any, isRaw: boolean, dataSource?: DataSource, env?: Env): Promise<any> {
2324
// ## DO NOT REMOVE: TEMPLATE AFTER QUERY HOOK ##
2425

2526
return result;
2627
}
2728

28-
export async function executeQuery(sql: string, params: any | undefined, isRaw: boolean, dataSource?: DataSource): Promise<QueryResponse> {
29+
function cleanseQuery(sql: string): string {
30+
return sql.replaceAll('\n', ' ')
31+
}
32+
33+
// NOTE: This is a temporary stop-gap solution to connect to external data sources. Outerbase offers
34+
// an API to handle connecting to a large number of database types in a secure manner. However, the
35+
// goal here is to optimize on query latency from your data sources by connecting to them directly.
36+
// An upcoming update will move the Outerbase SDK to be used in StarbaseDB so this service can connect
37+
// to those database types without being required to funnel requests through the Outerbase API.
38+
async function executeExternalQuery(sql: string, params: any, isRaw: boolean, dataSource: DataSource, env?: Env): Promise<any> {
39+
if (!dataSource.externalConnection) {
40+
throw new Error('External connection not found.');
41+
}
42+
43+
// Convert params from array to object if needed
44+
let convertedParams = params;
45+
if (Array.isArray(params)) {
46+
let paramIndex = 0;
47+
convertedParams = params.reduce((acc, value, index) => ({
48+
...acc,
49+
[`param${index}`]: value
50+
}), {});
51+
sql = sql.replace(/\?/g, () => `:param${paramIndex++}`);
52+
}
53+
54+
const API_URL = 'https://app.outerbase.com';
55+
const response = await fetch(`${API_URL}/api/v1/ezql/raw`, {
56+
method: 'POST',
57+
headers: {
58+
'Content-Type': 'application/json',
59+
'X-Source-Token': dataSource.externalConnection.outerbaseApiKey,
60+
},
61+
body: JSON.stringify({
62+
query: cleanseQuery(sql),
63+
params: convertedParams,
64+
}),
65+
});
66+
67+
const results: any = await response.json();
68+
const items = results.response.results?.items;
69+
return await afterQuery(sql, items, isRaw, dataSource, env);
70+
}
71+
72+
export async function executeQuery(sql: string, params: any | undefined, isRaw: boolean, dataSource?: DataSource, env?: Env): Promise<QueryResponse> {
2973
if (!dataSource) {
3074
console.error('Data source not found.')
3175
return []
3276
}
3377

3478
if (dataSource.source === 'internal') {
3579
const response = await dataSource.internalConnection?.durableObject.executeQuery(sql, params, isRaw);
36-
return response ?? [];
80+
return await afterQuery(sql, response, isRaw, dataSource, env);
3781
} else {
38-
if (!dataSource.externalConnection) {
39-
throw new Error('External connection not found.');
40-
}
41-
42-
const API_URL = 'https://app.outerbase.com'
43-
const response = await fetch(`${API_URL}/api/v1/ezql/raw`, {
44-
method: 'POST',
45-
headers: {
46-
'Content-Type': 'application/json',
47-
'X-Source-Token': dataSource.externalConnection.outerbaseApiKey,
48-
},
49-
body: JSON.stringify({
50-
query: sql,
51-
// Params does not support arrays, so we ensure we only pass them an object.
52-
params: Array.isArray(params) ? {} : params,
53-
}),
54-
})
55-
56-
let results: any = await response.json();
57-
let items = results.response.results?.items;
58-
return afterQuery(sql, items, isRaw, dataSource);
59-
}
82+
return executeExternalQuery(sql, params, isRaw, dataSource, env);
83+
}
6084
}
6185

62-
export async function executeTransaction(queries: { sql: string; params?: any[] }[], isRaw: boolean, dataSource?: DataSource): Promise<QueryResponse> {
86+
export async function executeTransaction(queries: { sql: string; params?: any[] }[], isRaw: boolean, dataSource?: DataSource, env?: Env): Promise<QueryResponse> {
6387
if (!dataSource) {
6488
console.error('Data source not found.')
6589
return []
6690
}
6791

6892
if (dataSource.source === 'internal') {
69-
const response = await dataSource.internalConnection?.durableObject.executeTransaction(queries, isRaw);
70-
return response ?? [];
71-
} else {
72-
if (!dataSource.externalConnection) {
73-
throw new Error('External connection not found.');
74-
}
93+
const results: any[] = [];
7594

76-
const API_URL = 'https://app.outerbase.com';
95+
for (const query of queries) {
96+
const result = await dataSource.internalConnection?.durableObject.executeTransaction(queries, isRaw);
97+
if (result) {
98+
const processedResults = await Promise.all(
99+
result.map(r => afterQuery(query.sql, r, isRaw, dataSource, env))
100+
);
101+
results.push(...processedResults);
102+
}
103+
}
104+
105+
return results;
106+
} else {
77107
const results = [];
78108

79109
for (const query of queries) {
80-
const response = await fetch(`${API_URL}/api/v1/ezql/raw`, {
81-
method: 'POST',
82-
headers: {
83-
'Content-Type': 'application/json',
84-
'X-Source-Token': dataSource.externalConnection.outerbaseApiKey,
85-
},
86-
body: JSON.stringify({
87-
query: query.sql,
88-
// Params does not support arrays, so we ensure we only pass them an object
89-
params: Array.isArray(query.params) ? {} : query.params,
90-
}),
91-
});
92-
93-
const result: any = await response.json();
94-
const items = result.response.results?.items;
95-
results.push(afterQuery(query.sql, items, isRaw, dataSource));
110+
const result = await executeExternalQuery(query.sql, query.params, isRaw, dataSource, env);
111+
results.push(result);
96112
}
97-
113+
98114
return results;
99115
}
100116
}

worker-configuration.d.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,5 @@
33
interface Env {
44
AUTHORIZATION_TOKEN: "ABC123";
55
REGION: "auto";
6-
EXTERNAL_DB_TYPE: "postgres";
7-
OUTERBASE_API_KEY: "";
86
DATABASE_DURABLE_OBJECT: DurableObjectNamespace<import("./src/index").DatabaseDurableObject>;
97
}

0 commit comments

Comments
 (0)