Skip to content

Commit 7a5d0e5

Browse files
authored
feat: add namespace and table existence checks with creation methods (#18)
1 parent 7468bb0 commit 7a5d0e5

File tree

5 files changed

+417
-0
lines changed

5 files changed

+417
-0
lines changed

src/catalog/IcebergRestCatalog.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,93 @@ export class IcebergRestCatalog {
277277
async loadTable(id: TableIdentifier): Promise<TableMetadata> {
278278
return this.tableOps.loadTable(id)
279279
}
280+
281+
/**
282+
* Checks if a namespace exists in the catalog.
283+
*
284+
* @param id - Namespace identifier to check
285+
* @returns True if the namespace exists, false otherwise
286+
*
287+
* @example
288+
* ```typescript
289+
* const exists = await catalog.namespaceExists({ namespace: ['analytics'] });
290+
* console.log(exists); // true or false
291+
* ```
292+
*/
293+
async namespaceExists(id: NamespaceIdentifier): Promise<boolean> {
294+
return this.namespaceOps.namespaceExists(id)
295+
}
296+
297+
/**
298+
* Checks if a table exists in the catalog.
299+
*
300+
* @param id - Table identifier to check
301+
* @returns True if the table exists, false otherwise
302+
*
303+
* @example
304+
* ```typescript
305+
* const exists = await catalog.tableExists({ namespace: ['analytics'], name: 'events' });
306+
* console.log(exists); // true or false
307+
* ```
308+
*/
309+
async tableExists(id: TableIdentifier): Promise<boolean> {
310+
return this.tableOps.tableExists(id)
311+
}
312+
313+
/**
314+
* Creates a namespace if it does not exist.
315+
*
316+
* If the namespace already exists, does nothing.
317+
*
318+
* @param id - Namespace identifier to create
319+
* @param metadata - Optional metadata properties for the namespace
320+
*
321+
* @example
322+
* ```typescript
323+
* await catalog.createNamespaceIfNotExists(
324+
* { namespace: ['analytics'] },
325+
* { properties: { owner: 'data-team' } }
326+
* );
327+
* ```
328+
*/
329+
async createNamespaceIfNotExists(
330+
id: NamespaceIdentifier,
331+
metadata?: NamespaceMetadata
332+
): Promise<void> {
333+
await this.namespaceOps.createNamespaceIfNotExists(id, metadata)
334+
}
335+
336+
/**
337+
* Creates a table if it does not exist.
338+
*
339+
* If the table already exists, returns its metadata instead.
340+
*
341+
* @param namespace - Namespace to create the table in
342+
* @param request - Table creation request including name, schema, partition spec, etc.
343+
* @returns Table metadata for the created or existing table
344+
*
345+
* @example
346+
* ```typescript
347+
* const metadata = await catalog.createTableIfNotExists(
348+
* { namespace: ['analytics'] },
349+
* {
350+
* name: 'events',
351+
* schema: {
352+
* type: 'struct',
353+
* fields: [
354+
* { id: 1, name: 'id', type: 'long', required: true },
355+
* { id: 2, name: 'timestamp', type: 'timestamp', required: true }
356+
* ],
357+
* 'schema-id': 0
358+
* }
359+
* }
360+
* );
361+
* ```
362+
*/
363+
async createTableIfNotExists(
364+
namespace: NamespaceIdentifier,
365+
request: CreateTableRequest
366+
): Promise<TableMetadata> {
367+
return this.tableOps.createTableIfNotExists(namespace, request)
368+
}
280369
}

src/catalog/namespaces.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HttpClient } from '../http/types'
2+
import { IcebergError } from '../errors/IcebergError'
23
import type {
34
CreateNamespaceRequest,
45
CreateNamespaceResponse,
@@ -65,4 +66,33 @@ export class NamespaceOperations {
6566
properties: response.data.properties,
6667
}
6768
}
69+
70+
async namespaceExists(id: NamespaceIdentifier): Promise<boolean> {
71+
try {
72+
await this.client.request<void>({
73+
method: 'HEAD',
74+
path: `${this.prefix}/namespaces/${namespaceToPath(id.namespace)}`,
75+
})
76+
return true
77+
} catch (error) {
78+
if (error instanceof IcebergError && error.status === 404) {
79+
return false
80+
}
81+
throw error
82+
}
83+
}
84+
85+
async createNamespaceIfNotExists(
86+
id: NamespaceIdentifier,
87+
metadata?: NamespaceMetadata
88+
): Promise<void> {
89+
try {
90+
await this.createNamespace(id, metadata)
91+
} catch (error) {
92+
if (error instanceof IcebergError && error.status === 409) {
93+
return
94+
}
95+
throw error
96+
}
97+
}
6898
}

src/catalog/tables.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HttpClient } from '../http/types'
2+
import { IcebergError } from '../errors/IcebergError'
23
import type {
34
CreateTableRequest,
45
ListTablesResponse,
@@ -81,4 +82,39 @@ export class TableOperations {
8182

8283
return response.data.metadata
8384
}
85+
86+
async tableExists(id: TableIdentifier): Promise<boolean> {
87+
const headers: Record<string, string> = {}
88+
if (this.accessDelegation) {
89+
headers['X-Iceberg-Access-Delegation'] = this.accessDelegation
90+
}
91+
92+
try {
93+
await this.client.request<void>({
94+
method: 'HEAD',
95+
path: `${this.prefix}/namespaces/${namespaceToPath(id.namespace)}/tables/${id.name}`,
96+
headers,
97+
})
98+
return true
99+
} catch (error) {
100+
if (error instanceof IcebergError && error.status === 404) {
101+
return false
102+
}
103+
throw error
104+
}
105+
}
106+
107+
async createTableIfNotExists(
108+
namespace: NamespaceIdentifier,
109+
request: CreateTableRequest
110+
): Promise<TableMetadata> {
111+
try {
112+
return await this.createTable(namespace, request)
113+
} catch (error) {
114+
if (error instanceof IcebergError && error.status === 409) {
115+
return await this.loadTable({ namespace: namespace.namespace, name: request.name })
116+
}
117+
throw error
118+
}
119+
}
84120
}

test/catalog/namespaces.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, vi } from 'vitest'
22
import { NamespaceOperations } from '../../src/catalog/namespaces'
3+
import { IcebergError } from '../../src/errors/IcebergError'
34
import type { HttpClient } from '../../src/http/types'
45

56
describe('NamespaceOperations', () => {
@@ -263,4 +264,103 @@ describe('NamespaceOperations', () => {
263264
})
264265
})
265266
})
267+
268+
describe('namespaceExists', () => {
269+
it('should return true when namespace exists', async () => {
270+
const mockClient = createMockClient()
271+
vi.mocked(mockClient.request).mockResolvedValue({
272+
status: 200,
273+
headers: new Headers(),
274+
data: undefined,
275+
})
276+
277+
const ops = new NamespaceOperations(mockClient, '/v1')
278+
const result = await ops.namespaceExists({ namespace: ['analytics'] })
279+
280+
expect(result).toBe(true)
281+
expect(mockClient.request).toHaveBeenCalledWith({
282+
method: 'HEAD',
283+
path: '/v1/namespaces/analytics',
284+
})
285+
})
286+
287+
it('should return false when namespace does not exist', async () => {
288+
const mockClient = createMockClient()
289+
vi.mocked(mockClient.request).mockRejectedValue(
290+
new IcebergError('Not Found', { status: 404 })
291+
)
292+
293+
const ops = new NamespaceOperations(mockClient, '/v1')
294+
const result = await ops.namespaceExists({ namespace: ['analytics'] })
295+
296+
expect(result).toBe(false)
297+
})
298+
299+
it('should re-throw non-404 errors', async () => {
300+
const mockClient = createMockClient()
301+
const error = new IcebergError('Server Error', { status: 500 })
302+
vi.mocked(mockClient.request).mockRejectedValue(error)
303+
304+
const ops = new NamespaceOperations(mockClient, '/v1')
305+
306+
await expect(ops.namespaceExists({ namespace: ['analytics'] })).rejects.toThrow(error)
307+
})
308+
})
309+
310+
describe('createNamespaceIfNotExists', () => {
311+
it('should create namespace if it does not exist', async () => {
312+
const mockClient = createMockClient()
313+
vi.mocked(mockClient.request).mockResolvedValueOnce({
314+
status: 200,
315+
headers: new Headers(),
316+
data: {
317+
namespace: ['analytics'],
318+
properties: { owner: 'data-team' },
319+
},
320+
})
321+
322+
const ops = new NamespaceOperations(mockClient, '/v1')
323+
await ops.createNamespaceIfNotExists(
324+
{ namespace: ['analytics'] },
325+
{ properties: { owner: 'data-team' } }
326+
)
327+
328+
expect(mockClient.request).toHaveBeenCalledTimes(1)
329+
expect(mockClient.request).toHaveBeenCalledWith({
330+
method: 'POST',
331+
path: '/v1/namespaces',
332+
body: {
333+
namespace: ['analytics'],
334+
properties: { owner: 'data-team' },
335+
},
336+
})
337+
})
338+
339+
it('should do nothing if namespace already exists', async () => {
340+
const mockClient = createMockClient()
341+
vi.mocked(mockClient.request).mockRejectedValueOnce(
342+
new IcebergError('Namespace already exists', { status: 409 })
343+
)
344+
345+
const ops = new NamespaceOperations(mockClient, '/v1')
346+
await ops.createNamespaceIfNotExists(
347+
{ namespace: ['analytics'] },
348+
{ properties: { owner: 'data-team' } }
349+
)
350+
351+
expect(mockClient.request).toHaveBeenCalledTimes(1)
352+
})
353+
354+
it('should re-throw non-409 errors', async () => {
355+
const mockClient = createMockClient()
356+
const error = new IcebergError('Server Error', { status: 500 })
357+
vi.mocked(mockClient.request).mockRejectedValue(error)
358+
359+
const ops = new NamespaceOperations(mockClient, '/v1')
360+
361+
await expect(
362+
ops.createNamespaceIfNotExists({ namespace: ['analytics'] })
363+
).rejects.toThrow(error)
364+
})
365+
})
266366
})

0 commit comments

Comments
 (0)