|
1 | 1 | import * as iam from '@aws-cdk/aws-iam';
|
2 | 2 | import * as kms from '@aws-cdk/aws-kms';
|
3 | 3 | import * as s3 from '@aws-cdk/aws-s3';
|
4 |
| -import { ArnFormat, Fn, IResource, Resource, Stack } from '@aws-cdk/core'; |
| 4 | +import { ArnFormat, Fn, IResource, Names, Resource, Stack } from '@aws-cdk/core'; |
| 5 | +import * as cr from '@aws-cdk/custom-resources'; |
| 6 | +import { AwsCustomResource } from '@aws-cdk/custom-resources'; |
5 | 7 | import { Construct } from 'constructs';
|
6 | 8 | import { DataFormat } from './data-format';
|
7 | 9 | import { IDatabase } from './database';
|
8 | 10 | import { CfnTable } from './glue.generated';
|
9 | 11 | import { Column } from './schema';
|
10 | 12 |
|
| 13 | +/** |
| 14 | + * Properties of a Partition Index. |
| 15 | + */ |
| 16 | +export interface PartitionIndex { |
| 17 | + /** |
| 18 | + * The name of the partition index. |
| 19 | + * |
| 20 | + * @default - a name will be generated for you. |
| 21 | + */ |
| 22 | + readonly indexName?: string; |
| 23 | + |
| 24 | + /** |
| 25 | + * The partition key names that comprise the partition |
| 26 | + * index. The names must correspond to a name in the |
| 27 | + * table's partition keys. |
| 28 | + */ |
| 29 | + readonly keyNames: string[]; |
| 30 | +} |
11 | 31 | export interface ITable extends IResource {
|
12 | 32 | /**
|
13 | 33 | * @attribute
|
@@ -102,7 +122,16 @@ export interface TableProps {
|
102 | 122 | *
|
103 | 123 | * @default table is not partitioned
|
104 | 124 | */
|
105 |
| - readonly partitionKeys?: Column[] |
| 125 | + readonly partitionKeys?: Column[]; |
| 126 | + |
| 127 | + /** |
| 128 | + * Partition indexes on the table. A maximum of 3 indexes |
| 129 | + * are allowed on a table. Keys in the index must be part |
| 130 | + * of the table's partition keys. |
| 131 | + * |
| 132 | + * @default table has no partition indexes |
| 133 | + */ |
| 134 | + readonly partitionIndexes?: PartitionIndex[]; |
106 | 135 |
|
107 | 136 | /**
|
108 | 137 | * Storage type of the table's data.
|
@@ -230,6 +259,18 @@ export class Table extends Resource implements ITable {
|
230 | 259 | */
|
231 | 260 | public readonly partitionKeys?: Column[];
|
232 | 261 |
|
| 262 | + /** |
| 263 | + * This table's partition indexes. |
| 264 | + */ |
| 265 | + public readonly partitionIndexes?: PartitionIndex[]; |
| 266 | + |
| 267 | + /** |
| 268 | + * Partition indexes must be created one at a time. To avoid |
| 269 | + * race conditions, we store the resource and add dependencies |
| 270 | + * each time a new partition index is created. |
| 271 | + */ |
| 272 | + private partitionIndexCustomResources: AwsCustomResource[] = []; |
| 273 | + |
233 | 274 | constructor(scope: Construct, id: string, props: TableProps) {
|
234 | 275 | super(scope, id, {
|
235 | 276 | physicalName: props.tableName,
|
@@ -287,6 +328,77 @@ export class Table extends Resource implements ITable {
|
287 | 328 | resourceName: `${this.database.databaseName}/${this.tableName}`,
|
288 | 329 | });
|
289 | 330 | this.node.defaultChild = tableResource;
|
| 331 | + |
| 332 | + // Partition index creation relies on created table. |
| 333 | + if (props.partitionIndexes) { |
| 334 | + this.partitionIndexes = props.partitionIndexes; |
| 335 | + this.partitionIndexes.forEach((index) => this.addPartitionIndex(index)); |
| 336 | + } |
| 337 | + } |
| 338 | + |
| 339 | + /** |
| 340 | + * Add a partition index to the table. You can have a maximum of 3 partition |
| 341 | + * indexes to a table. Partition index keys must be a subset of the table's |
| 342 | + * partition keys. |
| 343 | + * |
| 344 | + * @see https://docs.aws.amazon.com/glue/latest/dg/partition-indexes.html |
| 345 | + */ |
| 346 | + public addPartitionIndex(index: PartitionIndex) { |
| 347 | + const numPartitions = this.partitionIndexCustomResources.length; |
| 348 | + if (numPartitions >= 3) { |
| 349 | + throw new Error('Maximum number of partition indexes allowed is 3'); |
| 350 | + } |
| 351 | + this.validatePartitionIndex(index); |
| 352 | + |
| 353 | + const indexName = index.indexName ?? this.generateIndexName(index.keyNames); |
| 354 | + const partitionIndexCustomResource = new cr.AwsCustomResource(this, `partition-index-${indexName}`, { |
| 355 | + onCreate: { |
| 356 | + service: 'Glue', |
| 357 | + action: 'createPartitionIndex', |
| 358 | + parameters: { |
| 359 | + DatabaseName: this.database.databaseName, |
| 360 | + TableName: this.tableName, |
| 361 | + PartitionIndex: { |
| 362 | + IndexName: indexName, |
| 363 | + Keys: index.keyNames, |
| 364 | + }, |
| 365 | + }, |
| 366 | + physicalResourceId: cr.PhysicalResourceId.of( |
| 367 | + indexName, |
| 368 | + ), |
| 369 | + }, |
| 370 | + policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ |
| 371 | + resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE, |
| 372 | + }), |
| 373 | + }); |
| 374 | + this.grantToUnderlyingResources(partitionIndexCustomResource, ['glue:UpdateTable']); |
| 375 | + |
| 376 | + // Depend on previous partition index if possible, to avoid race condition |
| 377 | + if (numPartitions > 0) { |
| 378 | + this.partitionIndexCustomResources[numPartitions-1].node.addDependency(partitionIndexCustomResource); |
| 379 | + } |
| 380 | + this.partitionIndexCustomResources.push(partitionIndexCustomResource); |
| 381 | + } |
| 382 | + |
| 383 | + private generateIndexName(keys: string[]): string { |
| 384 | + const prefix = keys.join('-') + '-'; |
| 385 | + const uniqueId = Names.uniqueId(this); |
| 386 | + const maxIndexLength = 80; // arbitrarily specified |
| 387 | + const startIndex = Math.max(0, uniqueId.length - (maxIndexLength - prefix.length)); |
| 388 | + return prefix + uniqueId.substring(startIndex); |
| 389 | + } |
| 390 | + |
| 391 | + private validatePartitionIndex(index: PartitionIndex) { |
| 392 | + if (index.indexName !== undefined && (index.indexName.length < 1 || index.indexName.length > 255)) { |
| 393 | + throw new Error(`Index name must be between 1 and 255 characters, but got ${index.indexName.length}`); |
| 394 | + } |
| 395 | + if (!this.partitionKeys || this.partitionKeys.length === 0) { |
| 396 | + throw new Error('The table must have partition keys to create a partition index'); |
| 397 | + } |
| 398 | + const keyNames = this.partitionKeys.map(pk => pk.name); |
| 399 | + if (!index.keyNames.every(k => keyNames.includes(k))) { |
| 400 | + throw new Error(`All index keys must also be partition keys. Got ${index.keyNames} but partition key names are ${keyNames}`); |
| 401 | + } |
290 | 402 | }
|
291 | 403 |
|
292 | 404 | /**
|
@@ -336,6 +448,22 @@ export class Table extends Resource implements ITable {
|
336 | 448 | });
|
337 | 449 | }
|
338 | 450 |
|
| 451 | + /** |
| 452 | + * Grant the given identity custom permissions to ALL underlying resources of the table. |
| 453 | + * Permissions will be granted to the catalog, the database, and the table. |
| 454 | + */ |
| 455 | + public grantToUnderlyingResources(grantee: iam.IGrantable, actions: string[]) { |
| 456 | + return iam.Grant.addToPrincipal({ |
| 457 | + grantee, |
| 458 | + resourceArns: [ |
| 459 | + this.tableArn, |
| 460 | + this.database.catalogArn, |
| 461 | + this.database.databaseArn, |
| 462 | + ], |
| 463 | + actions, |
| 464 | + }); |
| 465 | + } |
| 466 | + |
339 | 467 | private getS3PrefixForGrant() {
|
340 | 468 | return this.s3Prefix + '*';
|
341 | 469 | }
|
|
0 commit comments