This is an automated email from the ASF dual-hosted git repository. XiaoHongbo-Hope pushed a commit to branch release-2.1 in repository https://gitbox.apache.org/repos/asf/paimon.git
commit a5f3dd4b99a48be16fa49dcac76a78e7c983c570 Author: Jingsong Lee <[email protected]> AuthorDate: Tue Sep 15 22:04:56 2026 +0800 [rest] Add semantic view definition management to REST Catalog (#9848) --- docs/docs/concepts/rest/management-api.md | 4 + docs/docs/concepts/rest/rest-api.md | 6 + docs/docs/concepts/rest/semantic-views.md | 168 +++++++++ docs/docs/program-api/rest-api.mdx | 8 + docs/scripts/validate-rest-openapi.js | 67 ++++ docs/static/rest-catalog-open-api.yaml | 269 ++++++++++++- .../paimon/management/SemanticViewManagement.java | 58 +++ .../main/java/org/apache/paimon/rest/RESTApi.java | 65 ++++ .../paimon/rest/RESTSemanticViewManagement.java | 66 ++++ .../java/org/apache/paimon/rest/ResourcePaths.java | 22 +- .../rest/requests/UpsertSemanticViewRequest.java | 51 +++ .../paimon/rest/responses/ErrorResponse.java | 1 + .../rest/responses/GetSemanticViewResponse.java | 66 ++++ .../rest/responses/ListSemanticViewsResponse.java | 75 ++++ .../java/org/apache/paimon/view/SemanticView.java | 42 +++ .../apache/paimon/view/SemanticViewDefinition.java | 84 +++++ .../paimon/rest/RESTApiSemanticViewTest.java | 420 +++++++++++++++++++++ .../requests/RequestJacksonCompatibilityTest.java | 1 + .../java/org/apache/paimon/rest/RESTCatalog.java | 7 + .../RESTCatalogSemanticViewManagementTest.java | 156 ++++++++ 20 files changed, 1632 insertions(+), 4 deletions(-) diff --git a/docs/docs/concepts/rest/management-api.md b/docs/docs/concepts/rest/management-api.md index 08726c2f62..d604b800b6 100644 --- a/docs/docs/concepts/rest/management-api.md +++ b/docs/docs/concepts/rest/management-api.md @@ -29,6 +29,10 @@ version is `1.0` and may evolve incompatibly while the design is being validated directly. These methods are intentionally not part of the generic `Catalog` interface. Other catalog implementations do not expose this management contract. +`RESTCatalog.semanticViewManagement()` provides [Semantic View definition management](semantic-views.md). +Its object CRUD routes are defined in the Catalog OpenAPI; permissions and labels use this +management contract. + `LabelManagement` provides generic label operations backed by the catalog's existing `RESTApi` client, authentication, and prefix configuration. The REST server must implement entity resolution, authorization, and atomic label storage. diff --git a/docs/docs/concepts/rest/rest-api.md b/docs/docs/concepts/rest/rest-api.md index 7be2bb8caf..18e54faea4 100644 --- a/docs/docs/concepts/rest/rest-api.md +++ b/docs/docs/concepts/rest/rest-api.md @@ -73,3 +73,9 @@ dropped, no data is deleted, and the request needs `replaceStatistics=true` with `partitionStatistics` entry for the same spec. Any other path under the table location stays invalid, so a server that does not implement this rejects the request rather than storing it. A server also rejects additive statistics for a partition that already has a custom location. + +## Semantic views + +See [Semantic Views](semantic-views.md) for experimental definition management, complete-document +POST upserts, and pagination. These use dedicated catalog routes and preserve the +existing SQL View contract. diff --git a/docs/docs/concepts/rest/semantic-views.md b/docs/docs/concepts/rest/semantic-views.md new file mode 100644 index 0000000000..bc3469dd5a --- /dev/null +++ b/docs/docs/concepts/rest/semantic-views.md @@ -0,0 +1,168 @@ +--- +title: "Semantic Views" +--- + +<!-- +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> + +# Semantic Views + +A semantic view is a named catalog object that stores a complete semantic model definition. +The model describes datasets, relationships, dimensions, measures, and related business semantics. +This experimental REST Catalog extension manages the object and its definition. The Java client +preserves the original document text. Servers validate the model formats, syntax versions, +and features they support; consumers provide query execution. + +Registration does not imply Spark/Flink execution support or grant access to source data. +This implementation supplies Java clients, wire models, and the +[Catalog OpenAPI contract](/rest-catalog-open-api.yaml). REST server providers must implement +persistence, validation, authorization, and the atomic operations described below. + +## REST operations + +```text +GET /v1/{prefix}/databases/{database}/semantic-views +GET /v1/{prefix}/databases/{database}/semantic-views/{semanticView} +POST /v1/{prefix}/databases/{database}/semantic-views/{semanticView} +DELETE /v1/{prefix}/databases/{database}/semantic-views/{semanticView} +``` + +Use the opaque prefix returned by config. Database and object names are independent UTF-8 encoded +path segments, including slashes, percent signs, spaces, Unicode, and dot-only names. Names are +never split on dots, and a catalog name inside model content does not determine the REST prefix. + +### Upsert and read + +POST creates or atomically replaces one complete definition in an existing database: + +```json +{ + "definition": { + "format": "databricks-yaml", + "content": "version: '1.1'\nsource: main.sales.orders\nmeasures:\n - name: revenue\n expr: SUM(paid_amount)\n" + } +} +``` + +`format` and `content` are required nonblank strings. `format` identifies both the model syntax +and document encoding, for example `databricks-yaml`, `snowflake-yaml`, or `ossie-yaml`. These are +Paimon format identifiers, not a closed enumeration; each server decides which formats it supports. +The example requires server support for `databricks-yaml`. Model syntax versions and expression +SQL dialects belong inside the content, following the chosen format's specification. +Clients do not parse, normalize, or discard fields in the content. +Content is limited to **1 MiB of UTF-8 bytes**, checked before sending by the Java client. + +POST and GET return HTTP 200 with the full object: + +```json +{ + "name": "order_metrics", + "definition": { + "format": "databricks-yaml", + "content": "version: '1.1'\nsource: main.sales.orders\nmeasures:\n - name: revenue\n expr: SUM(paid_amount)\n" + } +} +``` + +`name` is the local object name within the database addressed by the path. POST returns the +committed object, and subsequent reads must expose the complete committed definition. + +Upserts are unconditional: the last successful write takes effect. Each update replaces the +whole definition, so concurrent edits can overwrite one another. Replacement preserves identity, +creation metadata, owner, permissions, and labels. Definition-level comments and synonyms are +part of the complete replacement. + +### List and delete + +List accepts optional `maxResults` (1–1000; omitted uses the server default) and opaque `pageToken`: + +```json +{"semanticViews":["order_metrics"],"nextPageToken":"opaque-token"} +``` + +Lists return visible names only; GET retrieves a full definition. The last page omits the token. +An empty page terminates pagination and must not carry a continuation token. A missing database +returns 404; an existing database with no visible models returns an empty array. + +DELETE uses the object path with no query parameters or request body. It returns 200 without a +body; an absent object returns 404, and a blocking dependency returns 409. + +## Java catalog access + +```java +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.management.SemanticViewManagement; +import org.apache.paimon.view.SemanticView; +import org.apache.paimon.view.SemanticViewDefinition; + +SemanticViewManagement models = restCatalog.semanticViewManagement(); +Identifier id = Identifier.create("sales", "order_metrics"); +SemanticViewDefinition definition = new SemanticViewDefinition("databricks-yaml", yamlText); +SemanticView saved = models.upsertSemanticView(id, definition); +SemanticView current = models.getSemanticView(id); +models.upsertSemanticView(id, replacementDefinition); +models.listSemanticViews("sales"); // Follows all pages. +models.listSemanticViewsPaged("sales", 100, null); +models.deleteSemanticView(id); +``` + +The accessor reuses the catalog's REST client, prefix, authentication, and configured headers. +It is specific to `RESTCatalog`, not the generic `Catalog` interface. `RESTApi` also exposes the same +upsert/get/list/paged-list/delete operations; its read and upsert methods return `GetSemanticViewResponse`. +`SemanticView` does not implement ordinary `View.query()` or `View.rowType()`. + +## Server integration contract + +- Ordinary SQL views and semantic views share the database's view namespace. Both creation paths + must reject a cross-type name collision with 409. They cannot implicitly convert each other. +- Existing `/views`, `/view-details`, and global view lists return ordinary SQL views only. + Existing get/alter/drop SQL view routes treat a semantic view name as a missing ordinary view (404). + Existing table/function naming rules continue to apply. +- Reuse permission `ResourceType.VIEW` and structured database/view identities. Creation checks + the parent database's `CREATEVIEW`; replacement checks `ALTER`; deletion checks `DROP`; reads + check `SELECT`. Discovery follows database `LIST` and object visibility rules. Server resource + resolution must understand the semantic subtype. Source access is checked separately at execution. +- Labels use `entityType=VIEW` and a canonical `entityName` supplied according to the provider's + naming and escaping rules; the semantic view response does not supply that label identity. + Clients must not assume that concatenating database and view names with a dot produces it. + The label resolver must support semantic views. Definition replacement preserves bindings; + deletion cleans up direct labels and permissions without deleting referenced sources. +- Semantic views count when determining whether a database is empty. Database cascade deletion + follows existing catalog rules and cleans up semantic metadata and its direct bindings. +- Unsupported model semantics must be rejected without side effects or dropping fields. SQL + analysis, source validation, dependencies, and execution authorization belong to the server's + model format adapter. The client exposes no generic executable-validation status. + +The HTTP client tests verify wire behavior and error propagation. They do not prove a provider's +storage atomicity, permission enforcement, namespace isolation, or database lifecycle behavior. + +## Errors and scope + +Errors use `ErrorResponse`; semantic view errors use `resourceType=SEMANTIC_VIEW`. + +| HTTP | Meaning | Java client exception | +| --- | --- | --- | +| 400 | Invalid input or unsupported model format, syntax version, or feature | `BadRequestException` | +| 401 / 403 | Authentication or permission failure | `NotAuthorizedException` / `ForbiddenException` | +| 404 | Missing database or model | `NoSuchResourceException` | +| 409 | Name conflict or dependency blocks deletion | `AlreadyExistsException` (existing REST mapping) | +| 413 | Content exceeds the size limit | `RESTException` | +| 501 | Server does not implement semantic views | `NotImplementedException` | + +Client-side invalid input throws `IllegalArgumentException` before HTTP. An unsupported endpoint +never falls back to SQL views, labels, or table options. Query compilation, measure rewriting, +materialization, rename, and per-measure or per-field REST resources are outside this version. diff --git a/docs/docs/program-api/rest-api.mdx b/docs/docs/program-api/rest-api.mdx index 0a1d9e3686..ed19d4e380 100644 --- a/docs/docs/program-api/rest-api.mdx +++ b/docs/docs/program-api/rest-api.mdx @@ -141,3 +141,11 @@ To use the full table API, add the [Java bundle](java-api#dependency) and create `metastore=rest`, the server `uri`, `warehouse`, and the same authentication options. See [REST catalog configuration](../concepts/rest/) for an example. Loading table metadata and reading its data files are separate operations, so configure storage access as required by the catalog. + +## Semantic view definitions + +`RESTApi` exposes `upsertSemanticView`, `getSemanticView`, `listSemanticViews`, +`listSemanticViewsPaged`, and `deleteSemanticView`. Upsert and get return `GetSemanticViewResponse`. +Use `RESTCatalog.semanticViewManagement()` for the domain API backed by an existing catalog. +See [Semantic Views](../concepts/rest/semantic-views.md) for Java examples, upsert behavior, +server integration requirements, and the definition format. diff --git a/docs/scripts/validate-rest-openapi.js b/docs/scripts/validate-rest-openapi.js index 0d3c40155c..e51eb2295d 100644 --- a/docs/scripts/validate-rest-openapi.js +++ b/docs/scripts/validate-rest-openapi.js @@ -346,6 +346,73 @@ function validateCatalogOpenApi() { ['GetDatabaseResponse', 'GetTableResponse', 'GetViewResponse', 'GetFunctionResponse'].forEach( (schemaName) => contract.requireTypedIntegerProperties(schemaName, ['createdAt', 'updatedAt']), ); + const semanticCollection = '/v1/{prefix}/databases/{database}/semantic-views'; + const semanticItem = `${semanticCollection}/{semanticView}`; + [ + [semanticCollection, 'get', 'listSemanticViews'], + [semanticItem, 'get', 'getSemanticView'], + [semanticItem, 'post', 'upsertSemanticView'], + [semanticItem, 'delete', 'deleteSemanticView'], + ].forEach(([resourcePath, method, operationId]) => { + contract.checkSpec( + contract.spec.paths[resourcePath]?.[method]?.operationId === operationId, + `${operationId} must use ${method.toUpperCase()} ${resourcePath}`, + ); + contract.requireResponses(operationId, ['200', '400', '401', '403', '404', '500', '501']); + }); + contract.checkSpec(!contract.spec.paths[semanticItem].patch, 'Semantic views use POST replacement'); + contract.requireResponses('upsertSemanticView', ['409', '413']); + contract.requireResponses('deleteSemanticView', ['409']); + const semanticUpsert = contract.requireOperation('upsertSemanticView'); + contract.checkSpec( + semanticUpsert.requestBody.required && + semanticUpsert.requestBody.content['application/json'].schema.$ref === + '#/components/schemas/UpsertSemanticViewRequest' && + semanticUpsert.responses['200'].content['application/json'].schema.$ref === + '#/components/schemas/GetSemanticViewResponse', + 'Semantic view upsert must take a complete definition and return the committed object', + ); + const semanticDelete = contract.requireOperation('deleteSemanticView'); + contract.checkSpec( + !semanticDelete.requestBody && + !(semanticDelete.parameters || []).some((parameter) => parameter.in === 'query'), + 'Semantic view DELETE must use only the resource path without query parameters or a body', + ); + const pageSize = contract.requireOperation('listSemanticViews').parameters.find( + (parameter) => parameter.name === 'maxResults', + ); + contract.checkSpec( + pageSize.schema.minimum === 1 && pageSize.schema.maximum === 1000, + 'Semantic view page size must be between 1 and 1000', + ); + contract.requireRequiredProperties('SemanticViewDefinition', ['format', 'content']); + const definition = contract.requireProperties('SemanticViewDefinition', ['format', 'content']); + contract.checkSpec( + Object.keys(definition).length === 2 && !definition.format.enum && + definition.content['x-max-utf8-bytes'] === 1048576, + 'Semantic definitions require only an extensible format and content with a 1 MiB UTF-8 limit', + ); + contract.requireRequiredProperties('UpsertSemanticViewRequest', ['definition']); + contract.requireRequiredProperties('GetSemanticViewResponse', ['name', 'definition']); + contract.checkSpec( + Object.keys(contract.requireProperties('UpsertSemanticViewRequest', ['definition'])).length === 1 && + Object.keys(contract.requireProperties('GetSemanticViewResponse', ['name', 'definition'])).length === 2, + 'Semantic upserts contain only a definition; responses contain only a name and definition', + ); + const semanticNames = contract.requireProperties('ListSemanticViewsResponse', ['semanticViews', 'nextPageToken']); + contract.checkSpec( + semanticNames.semanticViews.type === 'array' && semanticNames.semanticViews.items.type === 'string', + 'Semantic view lists must contain names only', + ); + contract.checkSpec( + errorResourceTypes.includes('SEMANTIC_VIEW'), + 'Semantic view errors must identify SEMANTIC_VIEW', + ); + contract.checkSpec( + contract.requireOperation('alterView').requestBody.content['application/json'].schema.$ref === + '#/components/schemas/AlterViewRequest', + 'Ordinary view POST must retain its existing alteration request', + ); return contract.operations.size; } diff --git a/docs/static/rest-catalog-open-api.yaml b/docs/static/rest-catalog-open-api.yaml index 772555f067..02ee6df595 100644 --- a/docs/static/rest-catalog-open-api.yaml +++ b/docs/static/rest-catalog-open-api.yaml @@ -2225,11 +2225,231 @@ paths: "500": $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/databases/{database}/semantic-views: + parameters: + - name: prefix + in: path + required: true + description: Opaque catalog prefix returned by config. + schema: + type: string + minLength: 1 + - name: database + in: path + required: true + description: Database name encoded as one UTF-8 path segment. + schema: + type: string + minLength: 1 + get: + tags: [ semantic-view ] + summary: List Semantic Views + operationId: listSemanticViews + description: >- + Lists visible semantic view names only. Ordinary SQL view lists do not include these objects. + Missing databases return 404. Empty pages terminate pagination and omit nextPageToken. + parameters: + - name: maxResults + in: query + description: Maximum page size; omission uses the server default. + schema: + type: integer + format: int32 + minimum: 1 + maximum: 1000 + - name: pageToken + in: query + description: Opaque continuation token from the preceding response. + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ListSemanticViewsResponse' + "400": + $ref: '#/components/responses/BadRequestErrorResponse' + "401": + $ref: '#/components/responses/UnauthorizedErrorResponse' + "403": + $ref: '#/components/responses/ForbiddenErrorResponse' + "404": + $ref: '#/components/responses/SemanticViewNotExistErrorResponse' + "500": + $ref: '#/components/responses/ServerErrorResponse' + "501": + $ref: '#/components/responses/SemanticViewNotImplementedErrorResponse' + /v1/{prefix}/databases/{database}/semantic-views/{semanticView}: + parameters: + - name: prefix + in: path + required: true + description: Opaque catalog prefix returned by config. + schema: + type: string + minLength: 1 + - name: database + in: path + required: true + description: Database name encoded as one UTF-8 path segment. + schema: + type: string + minLength: 1 + - name: semanticView + in: path + required: true + description: Semantic view name encoded as one UTF-8 path segment, including dot-only names. + schema: + type: string + minLength: 1 + get: + tags: [ semantic-view ] + summary: Get Semantic View + operationId: getSemanticView + description: Returns the name and complete stored definition. + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetSemanticViewResponse' + "400": + $ref: '#/components/responses/BadRequestErrorResponse' + "401": + $ref: '#/components/responses/UnauthorizedErrorResponse' + "403": + $ref: '#/components/responses/ForbiddenErrorResponse' + "404": + $ref: '#/components/responses/SemanticViewNotExistErrorResponse' + "500": + $ref: '#/components/responses/ServerErrorResponse' + "501": + $ref: '#/components/responses/SemanticViewNotImplementedErrorResponse' + post: + tags: [ semantic-view ] + summary: Upsert Semantic View + operationId: upsertSemanticView + description: >- + Creates or atomically replaces the complete definition in an existing database. + Replacements preserve identity, creation metadata, owner, permissions, and labels. + Upserts are unconditional; the last successful write takes effect. + Ordinary SQL views share the view namespace; + both SQL view creation and semantic view upsert reject cross-type name collisions with 409. + The server validates supported model formats, syntax versions, and model features, + rejecting unsupported semantics without side effects or silently dropping fields. + Registration does not imply query engine support or grant access to sources. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertSemanticViewRequest' + example: + definition: + format: databricks-yaml + content: | + version: "1.1" + source: main.sales.orders + measures: + - name: revenue + expr: SUM(paid_amount) + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetSemanticViewResponse' + "400": + $ref: '#/components/responses/BadRequestErrorResponse' + "401": + $ref: '#/components/responses/UnauthorizedErrorResponse' + "403": + $ref: '#/components/responses/ForbiddenErrorResponse' + "404": + $ref: '#/components/responses/SemanticViewNotExistErrorResponse' + "409": + $ref: '#/components/responses/SemanticViewConflictErrorResponse' + "413": + $ref: '#/components/responses/SemanticViewTooLargeErrorResponse' + "500": + $ref: '#/components/responses/ServerErrorResponse' + "501": + $ref: '#/components/responses/SemanticViewNotImplementedErrorResponse' + delete: + tags: [ semantic-view ] + summary: Delete Semantic View + operationId: deleteSemanticView + description: >- + Deletes the object and its direct labels and permissions, never referenced sources. + Missing objects return 404. Dependencies may prevent deletion with 409. + No query parameters or request body are accepted. + responses: + "200": + description: Success, no content + "400": + $ref: '#/components/responses/BadRequestErrorResponse' + "401": + $ref: '#/components/responses/UnauthorizedErrorResponse' + "403": + $ref: '#/components/responses/ForbiddenErrorResponse' + "404": + $ref: '#/components/responses/SemanticViewNotExistErrorResponse' + "409": + $ref: '#/components/responses/SemanticViewConflictErrorResponse' + "500": + $ref: '#/components/responses/ServerErrorResponse' + "501": + $ref: '#/components/responses/SemanticViewNotImplementedErrorResponse' + components: ############################# # Reusable Response Objects # ############################# responses: + SemanticViewNotExistErrorResponse: + description: Database or semantic view does not exist. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + message: Database or semantic view does not exist. + code: 404 + resourceType: SEMANTIC_VIEW + SemanticViewConflictErrorResponse: + description: SQL view name collision or dependency prevents deletion. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + message: SQL view name collision or dependency prevents deletion. + code: 409 + resourceType: SEMANTIC_VIEW + SemanticViewTooLargeErrorResponse: + description: Definition content exceeds 1 MiB in UTF-8 bytes; no content is stored or truncated. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + message: Definition content exceeds 1 MiB in UTF-8 bytes; no content is stored or truncated. + code: 413 + resourceType: SEMANTIC_VIEW + SemanticViewNotImplementedErrorResponse: + description: The server does not implement semantic view management. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + message: The server does not implement semantic view management. + code: 501 + resourceType: SEMANTIC_VIEW BadRequestErrorResponse: description: Used for 400 errors, means illegal request. @@ -2445,6 +2665,53 @@ components: message: Internal Server Error code: 500 schemas: + SemanticViewDefinition: + type: object + required: [ format, content ] + properties: + format: + type: string + minLength: 1 + description: >- + Nonblank, extensible model document format identifying both model syntax and encoding, + for example databricks-yaml, snowflake-yaml, or ossie-yaml. These are Paimon format + identifiers; servers decide which formats they support and reject unsupported ones. + Model syntax versions and expression dialects belong inside the content. + examples: [ databricks-yaml, snowflake-yaml, ossie-yaml ] + content: + type: string + minLength: 1 + description: >- + Complete nonblank document, preserved verbatim, including future fields. + Maximum 1048576 UTF-8 bytes (not characters). Servers must never silently truncate. + A version inside the document identifies model syntax. + x-max-utf8-bytes: 1048576 + UpsertSemanticViewRequest: + type: object + required: [ definition ] + properties: + definition: + $ref: '#/components/schemas/SemanticViewDefinition' + GetSemanticViewResponse: + type: object + required: [ name, definition ] + properties: + name: + type: string + definition: + $ref: '#/components/schemas/SemanticViewDefinition' + ListSemanticViewsResponse: + type: object + required: [ semanticViews ] + properties: + semanticViews: + type: array + items: + type: string + description: Visible semantic view names only, without full definitions. + nextPageToken: + type: string + description: Opaque continuation token; omitted on the last or an empty page. CreateDatabaseRequest: type: object properties: @@ -2591,7 +2858,7 @@ components: type: string resourceType: type: [ string, "null" ] - enum: [ "DATABASE", "TABLE", "PARTITION", "COLUMN", "SNAPSHOT", "BRANCH", "TAG", "VIEW", "DIALECT", "FUNCTION", "DEFINITION", "UNKNOWN", null ] + enum: [ "DATABASE", "TABLE", "PARTITION", "COLUMN", "SNAPSHOT", "BRANCH", "TAG", "VIEW", "SEMANTIC_VIEW", "DIALECT", "FUNCTION", "DEFINITION", "UNKNOWN", null ] resourceName: type: [ string, "null" ] code: diff --git a/paimon-api/src/main/java/org/apache/paimon/management/SemanticViewManagement.java b/paimon-api/src/main/java/org/apache/paimon/management/SemanticViewManagement.java new file mode 100644 index 0000000000..07b7f60903 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/management/SemanticViewManagement.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.management; + +import org.apache.paimon.PagedList; +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.view.SemanticView; +import org.apache.paimon.view.SemanticViewDefinition; + +import javax.annotation.Nullable; + +import java.util.List; + +/** Definition management for semantic views. Registration does not imply query engine support. */ +@Experimental +public interface SemanticViewManagement { + + /** + * Creates or atomically replaces the complete definition, preserving identity and grants. The + * last successful write takes effect. + */ + SemanticView upsertSemanticView(Identifier identifier, SemanticViewDefinition definition); + + SemanticView getSemanticView(Identifier identifier); + + /** Lists names only, following pagination. */ + default List<String> listSemanticViews(String database) { + return PagedList.listAllFromPagedApi( + token -> listSemanticViewsPaged(database, null, token)); + } + + /** Lists names with a page size of 1 to 1000, or the server default when null. */ + PagedList<String> listSemanticViewsPaged( + String database, @Nullable Integer maxResults, @Nullable String pageToken); + + /** + * Deletes the object and its direct management bindings, never its sources. Missing objects + * fail with 404. + */ + void deleteSemanticView(Identifier identifier); +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java index 79c9fea3bf..8246cb1533 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java @@ -69,6 +69,7 @@ import org.apache.paimon.rest.requests.RevokePermissionRequest; import org.apache.paimon.rest.requests.RollbackSchemaRequest; import org.apache.paimon.rest.requests.RollbackTableRequest; import org.apache.paimon.rest.requests.UpsertLabelRequest; +import org.apache.paimon.rest.requests.UpsertSemanticViewRequest; import org.apache.paimon.rest.responses.AlterDatabaseResponse; import org.apache.paimon.rest.responses.AuthTableQueryResponse; import org.apache.paimon.rest.responses.CommitTableResponse; @@ -80,6 +81,7 @@ import org.apache.paimon.rest.responses.GetDatabaseResponse; import org.apache.paimon.rest.responses.GetFunctionResponse; import org.apache.paimon.rest.responses.GetLabelResponse; import org.apache.paimon.rest.responses.GetSchemaResponse; +import org.apache.paimon.rest.responses.GetSemanticViewResponse; import org.apache.paimon.rest.responses.GetTableResponse; import org.apache.paimon.rest.responses.GetTableSnapshotResponse; import org.apache.paimon.rest.responses.GetTableTokenResponse; @@ -97,6 +99,7 @@ import org.apache.paimon.rest.responses.ListPartitionsResponse; import org.apache.paimon.rest.responses.ListPermissionsResponse; import org.apache.paimon.rest.responses.ListPoliciesResponse; import org.apache.paimon.rest.responses.ListSchemasResponse; +import org.apache.paimon.rest.responses.ListSemanticViewsResponse; import org.apache.paimon.rest.responses.ListSnapshotsResponse; import org.apache.paimon.rest.responses.ListTableDetailsResponse; import org.apache.paimon.rest.responses.ListTablesGloballyResponse; @@ -114,6 +117,7 @@ import org.apache.paimon.table.TableSnapshot; import org.apache.paimon.utils.JsonSerdeUtil; import org.apache.paimon.utils.Pair; import org.apache.paimon.utils.StringUtils; +import org.apache.paimon.view.SemanticViewDefinition; import org.apache.paimon.view.ViewChange; import org.apache.paimon.view.ViewSchema; @@ -962,6 +966,67 @@ public class RESTApi { client.delete(resourcePaths.label(entityType, entityName, key), restAuthFunction); } + /** + * Creates or atomically replaces the complete semantic model definition. The last successful + * write takes effect. SQL view name conflicts return HTTP 409, mapped to + * AlreadyExistsException. + */ + @Experimental + public GetSemanticViewResponse upsertSemanticView( + Identifier identifier, SemanticViewDefinition definition) { + checkArgument(identifier != null, "identifier must not be null"); + return client.post( + resourcePaths.semanticView( + identifier.getDatabaseName(), identifier.getObjectName()), + new UpsertSemanticViewRequest(definition), + GetSemanticViewResponse.class, + restAuthFunction); + } + + /** Gets the complete model definition. Missing objects return HTTP 404. */ + @Experimental + public GetSemanticViewResponse getSemanticView(Identifier identifier) { + checkArgument(identifier != null, "identifier must not be null"); + return client.get( + resourcePaths.semanticView( + identifier.getDatabaseName(), identifier.getObjectName()), + GetSemanticViewResponse.class, + restAuthFunction); + } + + /** Lists semantic view names only, following catalog pagination. */ + @Experimental + public List<String> listSemanticViews(String database) { + return PagedList.listAllFromPagedApi( + token -> listSemanticViewsPaged(database, null, token)); + } + + /** Lists names with a page size of 1 to 1000, or the server default when null. */ + @Experimental + public PagedList<String> listSemanticViewsPaged( + String database, @Nullable Integer maxResults, @Nullable String pageToken) { + checkArgument( + maxResults == null || (maxResults >= 1 && maxResults <= 1000), + "maxResults must be between 1 and 1000"); + ListSemanticViewsResponse response = + client.get( + resourcePaths.semanticViews(database), + buildPagedQueryParams(maxResults, pageToken), + ListSemanticViewsResponse.class, + restAuthFunction); + return new PagedList<>(response.getSemanticViews(), response.getNextPageToken()); + } + + /** Deletes a semantic view; an absent object returns HTTP 404. */ + @Experimental + public void deleteSemanticView(Identifier identifier) { + checkArgument(identifier != null, "identifier must not be null"); + client.delete( + resourcePaths.semanticView( + identifier.getDatabaseName(), identifier.getObjectName()), + restAuthFunction); + } + /** Lists permissions on an exact resource in the configured REST catalog. */ @Experimental public ListPermissionsResponse listPermissions(ListPermissionsRequest request) { diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTSemanticViewManagement.java b/paimon-api/src/main/java/org/apache/paimon/rest/RESTSemanticViewManagement.java new file mode 100644 index 0000000000..095da3b1fe --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTSemanticViewManagement.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest; + +import org.apache.paimon.PagedList; +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.management.SemanticViewManagement; +import org.apache.paimon.rest.responses.GetSemanticViewResponse; +import org.apache.paimon.view.SemanticView; +import org.apache.paimon.view.SemanticViewDefinition; + +import javax.annotation.Nullable; + +/** Semantic view management using the configured REST catalog prefix and authentication. */ +@Experimental +public class RESTSemanticViewManagement implements SemanticViewManagement { + + private final RESTApi api; + + public RESTSemanticViewManagement(RESTApi api) { + this.api = api; + } + + @Override + public SemanticView upsertSemanticView( + Identifier identifier, SemanticViewDefinition definition) { + return toSemanticView(api.upsertSemanticView(identifier, definition)); + } + + @Override + public SemanticView getSemanticView(Identifier identifier) { + return toSemanticView(api.getSemanticView(identifier)); + } + + @Override + public PagedList<String> listSemanticViewsPaged( + String database, @Nullable Integer maxResults, @Nullable String pageToken) { + return api.listSemanticViewsPaged(database, maxResults, pageToken); + } + + @Override + public void deleteSemanticView(Identifier identifier) { + api.deleteSemanticView(identifier); + } + + private static SemanticView toSemanticView(GetSemanticViewResponse response) { + return new SemanticView(response.getName(), response.getDefinition()); + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java index 92cd466a22..f3065708da 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java @@ -40,6 +40,7 @@ public class ResourcePaths { protected static final String CONSUMERS = "consumers"; protected static final String SCHEMAS = "schemas"; protected static final String VIEWS = "views"; + protected static final String SEMANTIC_VIEWS = "semantic-views"; protected static final String TABLE_DETAILS = "table-details"; protected static final String VIEW_DETAILS = "view-details"; protected static final String ROLLBACK = "rollback"; @@ -75,16 +76,16 @@ public class ResourcePaths { checkArgument( entityName != null && !entityName.trim().isEmpty(), "entityName must not be blank"); return SLASH.join( - V1, prefix, LABELS, encodeLabelSegment(entityType), encodeLabelSegment(entityName)); + V1, prefix, LABELS, encodePathSegment(entityType), encodePathSegment(entityName)); } @Experimental public String label(String entityType, String entityName, String key) { checkArgument(key != null && !key.trim().isEmpty(), "key must not be blank"); - return SLASH.join(labels(entityType, entityName), encodeLabelSegment(key)); + return SLASH.join(labels(entityType, entityName), encodePathSegment(key)); } - private static String encodeLabelSegment(String value) { + private static String encodePathSegment(String value) { // Form encoding leaves dot segments unchanged, but they must be treated as names here. if (".".equals(value) || "..".equals(value)) { return value.replace(".", "%2E"); @@ -92,6 +93,21 @@ public class ResourcePaths { return encodeString(value); } + /** Semantic view names are encoded as independent path segments. */ + @Experimental + public String semanticViews(String database) { + checkArgument(database != null && !database.trim().isEmpty(), "database must not be blank"); + return SLASH.join(V1, prefix, DATABASES, encodePathSegment(database), SEMANTIC_VIEWS); + } + + @Experimental + public String semanticView(String database, String semanticView) { + checkArgument( + semanticView != null && !semanticView.trim().isEmpty(), + "semanticView must not be blank"); + return SLASH.join(semanticViews(database), encodePathSegment(semanticView)); + } + @Experimental public String permissions() { return SLASH.join(V1, prefix, PERMISSIONS); diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertSemanticViewRequest.java b/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertSemanticViewRequest.java new file mode 100644 index 0000000000..b025489b19 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/requests/UpsertSemanticViewRequest.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest.requests; + +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.rest.RESTRequest; +import org.apache.paimon.view.SemanticViewDefinition; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Unconditional replacement of the complete semantic view definition. */ +@Experimental +public class UpsertSemanticViewRequest implements RESTRequest { + + private final SemanticViewDefinition definition; + + @JsonCreator + @ConstructorProperties({"definition"}) + public UpsertSemanticViewRequest( + @JsonProperty("definition") SemanticViewDefinition definition) { + checkArgument(definition != null, "definition must not be null"); + this.definition = definition; + } + + @JsonGetter("definition") + public SemanticViewDefinition getDefinition() { + return definition; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java index 712a8cc110..007a18f68e 100644 --- a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java +++ b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java @@ -50,6 +50,7 @@ public class ErrorResponse implements RESTResponse { public static final String RESOURCE_TYPE_LABEL = "LABEL"; public static final String RESOURCE_TYPE_VIEW = "VIEW"; + public static final String RESOURCE_TYPE_SEMANTIC_VIEW = "SEMANTIC_VIEW"; public static final String RESOURCE_TYPE_DIALECT = "DIALECT"; diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSemanticViewResponse.java b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSemanticViewResponse.java new file mode 100644 index 0000000000..45b81288b2 --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/responses/GetSemanticViewResponse.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest.responses; + +import org.apache.paimon.annotation.Experimental; +import org.apache.paimon.rest.RESTResponse; +import org.apache.paimon.view.SemanticViewDefinition; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** A semantic view's name and complete committed definition. */ +@Experimental +public class GetSemanticViewResponse implements RESTResponse { + + private static final String FIELD_NAME = "name"; + private static final String FIELD_DEFINITION = "definition"; + + @JsonProperty(FIELD_NAME) + private final String name; + + @JsonProperty(FIELD_DEFINITION) + private final SemanticViewDefinition definition; + + @JsonCreator + @ConstructorProperties({FIELD_NAME, FIELD_DEFINITION}) + public GetSemanticViewResponse( + @JsonProperty(FIELD_NAME) String name, + @JsonProperty(FIELD_DEFINITION) SemanticViewDefinition definition) { + checkArgument(name != null && !name.trim().isEmpty(), "name must not be blank"); + checkArgument(definition != null, "definition must not be null"); + this.name = name; + this.definition = definition; + } + + @JsonGetter(FIELD_NAME) + public String getName() { + return name; + } + + @JsonGetter(FIELD_DEFINITION) + public SemanticViewDefinition getDefinition() { + return definition; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSemanticViewsResponse.java b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSemanticViewsResponse.java new file mode 100644 index 0000000000..66795a6d4e --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ListSemanticViewsResponse.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest.responses; + +import org.apache.paimon.annotation.Experimental; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import javax.annotation.Nullable; + +import java.beans.ConstructorProperties; +import java.util.List; + +import static java.util.Collections.emptyList; + +/** One page of semantic view names in a database. */ +@Experimental +public class ListSemanticViewsResponse implements PagedResponse<String> { + + private static final String FIELD_SEMANTIC_VIEWS = "semanticViews"; + private static final String FIELD_NEXT_PAGE_TOKEN = "nextPageToken"; + + @JsonProperty(FIELD_SEMANTIC_VIEWS) + private final List<String> semanticViews; + + @Nullable + @JsonProperty(FIELD_NEXT_PAGE_TOKEN) + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String nextPageToken; + + @JsonCreator + @ConstructorProperties({FIELD_SEMANTIC_VIEWS, FIELD_NEXT_PAGE_TOKEN}) + public ListSemanticViewsResponse( + @JsonProperty(FIELD_SEMANTIC_VIEWS) List<String> semanticViews, + @Nullable @JsonProperty(FIELD_NEXT_PAGE_TOKEN) String nextPageToken) { + this.semanticViews = semanticViews == null ? emptyList() : semanticViews; + this.nextPageToken = nextPageToken; + } + + @JsonGetter(FIELD_SEMANTIC_VIEWS) + public List<String> getSemanticViews() { + return semanticViews; + } + + @Override + @Nullable + @JsonGetter(FIELD_NEXT_PAGE_TOKEN) + public String getNextPageToken() { + return nextPageToken; + } + + @Override + public List<String> data() { + return semanticViews; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/view/SemanticView.java b/paimon-api/src/main/java/org/apache/paimon/view/SemanticView.java new file mode 100644 index 0000000000..888bee241e --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/view/SemanticView.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.view; + +import org.apache.paimon.annotation.Experimental; + +/** A registered semantic model, independent of a SQL view's query and result schema. */ +@Experimental +public class SemanticView { + + private final String name; + private final SemanticViewDefinition definition; + + public SemanticView(String name, SemanticViewDefinition definition) { + this.name = name; + this.definition = definition; + } + + public String getName() { + return name; + } + + public SemanticViewDefinition getDefinition() { + return definition; + } +} diff --git a/paimon-api/src/main/java/org/apache/paimon/view/SemanticViewDefinition.java b/paimon-api/src/main/java/org/apache/paimon/view/SemanticViewDefinition.java new file mode 100644 index 0000000000..d40099951d --- /dev/null +++ b/paimon-api/src/main/java/org/apache/paimon/view/SemanticViewDefinition.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.view; + +import org.apache.paimon.annotation.Experimental; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter; +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty; + +import java.beans.ConstructorProperties; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import static org.apache.paimon.utils.Preconditions.checkArgument; + +/** Complete semantic model text. Model format support is validated by the server. */ +@Experimental +public class SemanticViewDefinition { + + /** Maximum UTF-8 content size accepted by the semantic view REST contract. */ + public static final int MAX_CONTENT_BYTES = 1024 * 1024; + + private final String format; + private final String content; + + @JsonCreator + @ConstructorProperties({"format", "content"}) + public SemanticViewDefinition( + @JsonProperty("format") String format, @JsonProperty("content") String content) { + checkArgument(format != null && !format.trim().isEmpty(), "format must not be blank"); + checkArgument(content != null && !content.trim().isEmpty(), "content must not be blank"); + checkArgument( + content.getBytes(StandardCharsets.UTF_8).length <= MAX_CONTENT_BYTES, + "content must not exceed 1 MiB in UTF-8"); + this.format = format; + this.content = content; + } + + /** Identifies both model syntax and document encoding, for example {@code databricks-yaml}. */ + @JsonGetter("format") + public String getFormat() { + return format; + } + + /** Returns the original document without parsing or normalization. */ + @JsonGetter("content") + public String getContent() { + return content; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof SemanticViewDefinition)) { + return false; + } + SemanticViewDefinition that = (SemanticViewDefinition) obj; + return format.equals(that.format) && content.equals(that.content); + } + + @Override + public int hashCode() { + return Objects.hash(format, content); + } +} diff --git a/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiSemanticViewTest.java b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiSemanticViewTest.java new file mode 100644 index 0000000000..e753f9d066 --- /dev/null +++ b/paimon-api/src/test/java/org/apache/paimon/rest/RESTApiSemanticViewTest.java @@ -0,0 +1,420 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest; + +import org.apache.paimon.PagedList; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.options.Options; +import org.apache.paimon.rest.exceptions.AlreadyExistsException; +import org.apache.paimon.rest.exceptions.ForbiddenException; +import org.apache.paimon.rest.exceptions.NoSuchResourceException; +import org.apache.paimon.rest.exceptions.NotImplementedException; +import org.apache.paimon.rest.exceptions.RESTException; +import org.apache.paimon.rest.requests.UpsertSemanticViewRequest; +import org.apache.paimon.rest.responses.GetSemanticViewResponse; +import org.apache.paimon.rest.responses.ListSemanticViewsResponse; +import org.apache.paimon.view.SemanticViewDefinition; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import static org.apache.paimon.rest.RESTCatalogInternalOptions.PREFIX; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER; +import static org.apache.paimon.rest.RESTCatalogOptions.URI; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** HTTP contract tests for semantic view definition management. */ +class RESTApiSemanticViewTest { + + private static final String BASE_PATH = "/v1/catalog%2Fid/databases/sales/semantic-views"; + private static final Identifier IDENTIFIER = Identifier.create("sales", "revenue"); + private static final SemanticViewDefinition DEFINITION = + new SemanticViewDefinition( + "provider-v2-yaml", + "# 指标\r\nversion: '1.1'\nsource: db.orders\nfuture: {expr: \"a + b\\c\"}\n"); + + private final Queue<Reply> replies = new ConcurrentLinkedQueue<>(); + private final List<Request> requests = new CopyOnWriteArrayList<>(); + private HttpServer server; + private RESTApi api; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/v1/", + exchange -> { + requests.add(new Request(exchange)); + Reply reply = replies.poll(); + if (reply == null) { + reply = new Reply(500, "{\"code\":500,\"message\":\"unexpected request\"}"); + } + byte[] data = reply.body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(reply.code, data.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(data); + } finally { + exchange.close(); + } + }); + server.start(); + + Options options = new Options(); + options.set(URI, "http://127.0.0.1:" + server.getAddress().getPort()); + options.set(PREFIX, "catalog/id"); + options.set(TOKEN_PROVIDER, "bear"); + options.set(TOKEN, "test-token"); + api = new RESTApi(options, false); + } + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + void testCompleteDefinitionUpsertAndReadPreserveText() throws Exception { + for (int i = 0; i < 2; i++) { + enqueue(200, responseJson()); + GetSemanticViewResponse result = api.upsertSemanticView(IDENTIFIER, DEFINITION); + assertThat(result.getDefinition()).isEqualTo(DEFINITION); + assertThat(result.getName()).isEqualTo("revenue"); + } + enqueue(200, responseJson()); + assertThat(api.getSemanticView(IDENTIFIER).getDefinition()).isEqualTo(DEFINITION); + assertThat(requests).hasSize(3); + for (int i = 0; i < 2; i++) { + Request request = requests.get(i); + assertThat(request.method).isEqualTo("POST"); + assertThat(request.path).isEqualTo(BASE_PATH + "/revenue"); + assertThat(request.query).isNull(); + assertThat(request.authorization).isEqualTo("Bearer test-token"); + Map<?, ?> body = RESTApi.fromJson(request.body, Map.class); + assertThat(body.keySet()).extracting(Object::toString).containsExactly("definition"); + Map<String, String> definition = (Map<String, String>) body.get("definition"); + assertThat(definition) + .containsOnlyKeys("format", "content") + .containsEntry("format", "provider-v2-yaml") + .containsEntry("content", DEFINITION.getContent()); + } + assertThat(requests.get(2).method).isEqualTo("GET"); + } + + @Test + void testIndependentPathEncodingIncludingDotSegments() throws Exception { + Identifier id = Identifier.create("sales /华+%?#", "model.a/b+% ?#"); + enqueue(200, responseJson()); + api.upsertSemanticView(id, DEFINITION); + enqueue(200, responseJson()); + api.getSemanticView(id); + enqueue(200, ""); + api.deleteSemanticView(id); + String path = + "/v1/catalog%2Fid/databases/sales+%2F%E5%8D%8E%2B%25%3F%23/semantic-views/model.a%2Fb%2B%25+%3F%23"; + assertThat(requests).extracting(r -> r.path).containsExactly(path, path, path); + enqueue(200, responseJson()); + api.getSemanticView(Identifier.create(".", "..")); + assertThat(requests.get(3).path) + .isEqualTo("/v1/catalog%2Fid/databases/%2E/semantic-views/%2E%2E"); + } + + @Test + void testPagedAndAllNamesPreserveOpaqueTokens() throws Exception { + enqueue(200, "{\"semanticViews\":[\"one\"],\"nextPageToken\":\"next +/%?&\"}"); + PagedList<String> page = api.listSemanticViewsPaged("sales", 1000, "start +/%"); + assertThat(page.getElements()).containsExactly("one"); + assertThat(page.getNextPageToken()).isEqualTo("next +/%?&"); + assertThat(queryParameters(requests.get(0).query)) + .containsEntry("maxResults", "1000") + .containsEntry("pageToken", "start +/%"); + enqueue(200, "{\"semanticViews\":[\"one\"],\"nextPageToken\":\"next +/%?&\"}"); + enqueue(200, "{\"semanticViews\":[\"two\"]}"); + assertThat(api.listSemanticViews("sales")).containsExactly("one", "two"); + assertThat(requests.get(1).query).isNull(); + assertThat(queryParameters(requests.get(2).query)) + .containsOnlyKeys("pageToken") + .containsEntry("pageToken", "next +/%?&"); + assertThat(requests) + .allSatisfy( + r -> { + assertThat(r.method).isEqualTo("GET"); + assertThat(r.path).isEqualTo(BASE_PATH); + }); + // Even a malformed empty page with a continuation token must not keep fetching. + enqueue(200, "{\"semanticViews\":[],\"nextPageToken\":\"unused\"}"); + assertThat(api.listSemanticViews("sales")).isEmpty(); + assertThat(requests).hasSize(4); + } + + @Test + void testDeleteUsesIdentityWithoutBodyOrQuery() { + enqueue(200, ""); + api.deleteSemanticView(IDENTIFIER); + assertThat(requests).hasSize(1); + Request request = requests.get(0); + assertThat(request.method).isEqualTo("DELETE"); + assertThat(request.path).isEqualTo(BASE_PATH + "/revenue"); + assertThat(request.query).isNull(); + assertThat(request.body).isEmpty(); + assertThat(request.authorization).isEqualTo("Bearer test-token"); + } + + @Test + void testMissingObjectsPropagateWithoutFallback() { + List<Consumer<RESTApi>> operations = + Arrays.asList( + client -> client.upsertSemanticView(IDENTIFIER, DEFINITION), + client -> client.getSemanticView(IDENTIFIER), + client -> client.listSemanticViews("sales"), + client -> client.listSemanticViewsPaged("sales", 1, null), + client -> client.deleteSemanticView(IDENTIFIER)); + for (Consumer<RESTApi> operation : operations) { + enqueue( + 404, + "{\"code\":404,\"resourceType\":\"SEMANTIC_VIEW\",\"message\":\"missing model\"}"); + assertThatThrownBy(() -> operation.accept(api)) + .isInstanceOf(NoSuchResourceException.class) + .hasMessageContaining("missing model"); + } + assertThat(requests).hasSize(operations.size()); + } + + @Test + void testNameAndDependencyConflictsHaveNoFallback() { + enqueue(409, "{\"code\":409,\"message\":\"SQL view already exists\"}"); + assertThatThrownBy(() -> api.upsertSemanticView(IDENTIFIER, DEFINITION)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageContaining("SQL view already exists"); + enqueue(409, "{\"code\":409,\"message\":\"dependent objects\"}"); + assertThatThrownBy(() -> api.deleteSemanticView(IDENTIFIER)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageContaining("dependent objects"); + assertThat(requests).extracting(r -> r.method).containsExactly("POST", "DELETE"); + assertThat(requests).allSatisfy(r -> assertThat(r.path).isEqualTo(BASE_PATH + "/revenue")); + } + + @Test + void testForbiddenUnsupportedAndOversizedResponsesHaveNoFallback() { + enqueue(403, "{\"code\":403,\"message\":\"denied\"}"); + assertThatThrownBy(() -> api.upsertSemanticView(IDENTIFIER, DEFINITION)) + .isInstanceOf(ForbiddenException.class); + enqueue(501, "{\"code\":501,\"message\":\"unsupported\"}"); + assertThatThrownBy(() -> api.getSemanticView(IDENTIFIER)) + .isInstanceOf(NotImplementedException.class); + enqueue(413, "{\"code\":413,\"message\":\"too large\"}"); + assertThatThrownBy(() -> api.upsertSemanticView(IDENTIFIER, DEFINITION)) + .isInstanceOf(RESTException.class) + .hasMessageContaining("too large"); + assertThat(requests).hasSize(3); + } + + @Test + void testInvalidInputsAndUtf8SizeBoundaryBeforeHttp() { + for (String invalid : Arrays.asList(null, "", " ")) { + assertThatThrownBy(() -> new SemanticViewDefinition(invalid, "content")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new SemanticViewDefinition("provider-yaml", invalid)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> api.listSemanticViews(invalid)) + .isInstanceOf(IllegalArgumentException.class); + } + for (String invalid : Arrays.asList("", " ")) { + assertThatThrownBy(() -> api.getSemanticView(Identifier.create("sales", invalid))) + .isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy(() -> api.getSemanticView(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> api.upsertSemanticView(null, DEFINITION)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> api.deleteSemanticView(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> api.upsertSemanticView(IDENTIFIER, null)) + .isInstanceOf(IllegalArgumentException.class); + for (int invalid : new int[] {-1, 0, 1001}) { + assertThatThrownBy(() -> api.listSemanticViewsPaged("sales", invalid, null)) + .isInstanceOf(IllegalArgumentException.class); + } + char[] chars = new char[SemanticViewDefinition.MAX_CONTENT_BYTES / 3]; + Arrays.fill(chars, '华'); + String limit = new String(chars) + "x"; + assertThat(limit.getBytes(StandardCharsets.UTF_8)) + .hasSize(SemanticViewDefinition.MAX_CONTENT_BYTES); + assertThat(new SemanticViewDefinition("provider-yaml", limit).getContent()) + .isEqualTo(limit); + assertThatThrownBy(() -> new SemanticViewDefinition("provider-yaml", limit + "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 MiB"); + assertThat(requests).isEmpty(); + } + + @Test + void testIncompleteResponsesAreRejected() throws Exception { + for (String field : Arrays.asList("name", "definition")) { + Map<String, Object> response = RESTApi.fromJson(responseJson(), Map.class); + response.remove(field); + enqueue(200, RESTApi.toJson(response)); + assertThatThrownBy(() -> api.getSemanticView(IDENTIFIER)) + .isInstanceOf(RESTException.class); + } + assertThat(requests).hasSize(2); + } + + @Test + void testModelFormatsUseTwoFieldWireDefinitions() throws Exception { + for (String format : + Arrays.asList("databricks-yaml", "snowflake-yaml", "ossie-yaml", "provider-json")) { + // The REST layer transports provider-defined text without interpreting its grammar. + SemanticViewDefinition definition = + new SemanticViewDefinition(format, DEFINITION.getContent()); + enqueue(200, RESTApi.toJson(new GetSemanticViewResponse("revenue", definition))); + assertThat(api.upsertSemanticView(IDENTIFIER, definition).getDefinition()) + .isEqualTo(definition); + String body = requests.get(requests.size() - 1).body; + Map<?, ?> request = RESTApi.fromJson(body, Map.class); + Map<String, String> fields = (Map<String, String>) request.get("definition"); + assertThat(fields) + .containsOnlyKeys("format", "content") + .containsEntry("format", format) + .containsEntry("content", DEFINITION.getContent()); + UpsertSemanticViewRequest external = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(body, UpsertSemanticViewRequest.class); + assertThat(external.getDefinition()).isEqualTo(definition); + } + assertThat(requests).hasSize(4); + } + + @Test + void testNestedJacksonCompatibilityAndRequiredFields() throws Exception { + String json = RESTApi.toJson(new UpsertSemanticViewRequest(DEFINITION)); + UpsertSemanticViewRequest external = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(json, UpsertSemanticViewRequest.class); + assertThat(external.getDefinition()).isEqualTo(DEFINITION); + assertThat( + RESTApi.fromJson(RESTApi.toJson(external), UpsertSemanticViewRequest.class) + .getDefinition()) + .isEqualTo(DEFINITION); + assertThat( + RESTApi.fromJson( + RESTApi.toJson(new UpsertSemanticViewRequest(DEFINITION)), + Map.class)) + .containsOnlyKeys("definition"); + for (String invalid : + Arrays.asList( + "{}", + "{\"definition\":null}", + "{\"definition\":{}}", + "{\"definition\":{\"format\":\"databricks-yaml\"}}", + "{\"definition\":{\"content\":\"source: orders\"}}")) { + assertThatThrownBy(() -> RESTApi.fromJson(invalid, UpsertSemanticViewRequest.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + } + GetSemanticViewResponse response = + RESTApi.fromJson( + responseJson().replace("\"name\":", "\"future\":true,\"name\":"), + GetSemanticViewResponse.class); + assertThat(response.getDefinition()).isEqualTo(DEFINITION); + assertThat(RESTApi.fromJson(RESTApi.toJson(response), Map.class)) + .containsOnlyKeys("name", "definition"); + assertThat( + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(responseJson(), GetSemanticViewResponse.class) + .getDefinition()) + .isEqualTo(DEFINITION); + ListSemanticViewsResponse empty = + RESTApi.fromJson("{\"future\":true}", ListSemanticViewsResponse.class); + assertThat(empty.getSemanticViews()).isEmpty(); + assertThat(empty.getNextPageToken()).isNull(); + assertThat(RESTApi.fromJson(RESTApi.toJson(empty), Map.class)) + .containsOnlyKeys("semanticViews"); + } + + private static String responseJson() throws Exception { + return RESTApi.toJson(new GetSemanticViewResponse("revenue", DEFINITION)); + } + + private void enqueue(int code, String body) { + replies.add(new Reply(code, body)); + } + + private static Map<String, String> queryParameters(String query) throws Exception { + Map<String, String> values = new HashMap<>(); + for (String parameter : query.split("&")) { + String[] pair = parameter.split("=", 2); + values.put(URLDecoder.decode(pair[0], "UTF-8"), URLDecoder.decode(pair[1], "UTF-8")); + } + return values; + } + + private static class Reply { + private final int code; + private final String body; + + private Reply(int code, String body) { + this.code = code; + this.body = body; + } + } + + private static class Request { + private final String method; + private final String path; + private final String query; + private final String body; + private final String authorization; + + private Request(HttpExchange exchange) throws IOException { + method = exchange.getRequestMethod(); + path = exchange.getRequestURI().getRawPath(); + query = exchange.getRequestURI().getRawQuery(); + authorization = exchange.getRequestHeaders().getFirst("Authorization"); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[1024]; + int read; + while ((read = exchange.getRequestBody().read(data)) != -1) { + buffer.write(data, 0, read); + } + body = new String(buffer.toByteArray(), StandardCharsets.UTF_8); + } + } +} diff --git a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java index 5674e90b17..e7e8a5517e 100644 --- a/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java +++ b/paimon-api/src/test/java/org/apache/paimon/rest/requests/RequestJacksonCompatibilityTest.java @@ -166,6 +166,7 @@ public class RequestJacksonCompatibilityTest { private static final Set<Class<? extends RESTRequest>> COMPLEX_REQUESTS = Stream.<Class<? extends RESTRequest>>of( + UpsertSemanticViewRequest.class, AlterFunctionRequest.class, AlterTableRequest.class, AlterViewRequest.class, diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java index e18eae4d03..f691fc06f5 100644 --- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java +++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java @@ -43,6 +43,7 @@ import org.apache.paimon.function.FunctionChange; import org.apache.paimon.management.LabelManagement; import org.apache.paimon.management.PermissionManagement; import org.apache.paimon.management.PolicyManagement; +import org.apache.paimon.management.SemanticViewManagement; import org.apache.paimon.options.Options; import org.apache.paimon.partition.Partition; import org.apache.paimon.partition.PartitionStatistics; @@ -159,6 +160,12 @@ public class RESTCatalog implements Catalog { return new RESTLabelManagement(api); } + /** Definition management for semantic views, using this catalog's configuration. */ + @Experimental + public SemanticViewManagement semanticViewManagement() { + return new RESTSemanticViewManagement(api); + } + @Override public List<String> listDatabases() { return api.listDatabases(); diff --git a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogSemanticViewManagementTest.java b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogSemanticViewManagementTest.java new file mode 100644 index 0000000000..f2c3e9d4af --- /dev/null +++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogSemanticViewManagementTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.rest; + +import org.apache.paimon.PagedList; +import org.apache.paimon.catalog.CatalogContext; +import org.apache.paimon.catalog.Identifier; +import org.apache.paimon.management.SemanticViewManagement; +import org.apache.paimon.options.Options; +import org.apache.paimon.rest.responses.GetSemanticViewResponse; +import org.apache.paimon.view.SemanticView; +import org.apache.paimon.view.SemanticViewDefinition; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.apache.paimon.options.CatalogOptions.WAREHOUSE; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN; +import static org.apache.paimon.rest.RESTCatalogOptions.TOKEN_PROVIDER; +import static org.apache.paimon.rest.RESTCatalogOptions.URI; +import static org.assertj.core.api.Assertions.assertThat; + +/** Exercises semantic view management through a configured REST catalog and its HTTP client. */ +class RESTCatalogSemanticViewManagementTest { + + private static final String BASE_PATH = "/v1/catalog%2Fid/databases/sales/semantic-views"; + private static final Identifier ID = Identifier.create("sales", "revenue"); + private static final SemanticViewDefinition DEFINITION = + new SemanticViewDefinition("provider-yaml", "source: orders\n"); + + private MockWebServer server; + private RESTCatalog catalog; + private SemanticViewManagement models; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + enqueue( + 200, + "{\"defaults\":{},\"overrides\":{\"prefix\":\"catalog/id\"," + + "\"header.X-Catalog-Context\":\"configured\"}}"); + + Options options = new Options(); + options.set(URI, server.url("/").toString()); + options.set(WAREHOUSE, "warehouse-id"); + options.set(TOKEN_PROVIDER, "bear"); + options.set(TOKEN, "test-token"); + catalog = new RESTCatalog(CatalogContext.create(options)); + models = catalog.semanticViewManagement(); + + RecordedRequest config = server.takeRequest(10, TimeUnit.SECONDS); + assertThat(config).isNotNull(); + assertThat(config.getRequestUrl().encodedPath()).isEqualTo("/v1/config"); + assertThat(config.getRequestUrl().queryParameter("warehouse")).isEqualTo("warehouse-id"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + + @AfterEach + void tearDown() throws Exception { + if (catalog != null) { + catalog.close(); + } + if (server != null) { + server.shutdown(); + } + } + + @Test + void testLifecycleUsesSharedConfiguration() throws Exception { + String response = RESTApi.toJson(new GetSemanticViewResponse("revenue", DEFINITION)); + enqueue(200, response); + SemanticView model = models.upsertSemanticView(ID, DEFINITION); + Map<?, ?> body = + RESTApi.fromJson( + takeRequest("POST", BASE_PATH + "/revenue").getBody().readUtf8(), + Map.class); + assertThat(body.keySet()).extracting(Object::toString).containsExactly("definition"); + assertModel(model); + + enqueue(200, response); + assertModel(models.getSemanticView(ID)); + takeRequest("GET", BASE_PATH + "/revenue"); + + enqueue(200, ""); + models.deleteSemanticView(ID); + RecordedRequest deletion = takeRequest("DELETE", BASE_PATH + "/revenue"); + assertThat(deletion.getRequestUrl().query()).isNull(); + assertThat(deletion.getBodySize()).isZero(); + assertThat(server.getRequestCount()).isEqualTo(4); + } + + @Test + void testPagedAndFullListingThroughManagement() throws Exception { + enqueue(200, "{\"semanticViews\":[\"one\"],\"nextPageToken\":\"next +/%\"}"); + PagedList<String> page = models.listSemanticViewsPaged("sales", 1, "start +/%"); + assertThat(page.getElements()).containsExactly("one"); + assertThat(page.getNextPageToken()).isEqualTo("next +/%"); + RecordedRequest request = takeRequest("GET", BASE_PATH); + assertThat(request.getRequestUrl().queryParameter("maxResults")).isEqualTo("1"); + assertThat(request.getRequestUrl().queryParameter("pageToken")).isEqualTo("start +/%"); + enqueue(200, "{\"semanticViews\":[\"one\"],\"nextPageToken\":\"next +/%\"}"); + enqueue(200, "{\"semanticViews\":[\"two\"]}"); + assertThat(models.listSemanticViews("sales")).containsExactly("one", "two"); + assertThat(takeRequest("GET", BASE_PATH).getRequestUrl().query()).isNull(); + assertThat(takeRequest("GET", BASE_PATH).getRequestUrl().queryParameter("pageToken")) + .isEqualTo("next +/%"); + assertThat(server.getRequestCount()).isEqualTo(4); + } + + private static void assertModel(SemanticView model) { + assertThat(model.getName()).isEqualTo("revenue"); + assertThat(model.getDefinition()).isEqualTo(DEFINITION); + } + + private void enqueue(int status, String body) { + server.enqueue( + new MockResponse() + .setResponseCode(status) + .setHeader("Content-Type", "application/json") + .setBody(body)); + } + + private RecordedRequest takeRequest(String method, String path) throws Exception { + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + assertThat(request).isNotNull(); + assertThat(request.getMethod()).isEqualTo(method); + assertThat(request.getRequestUrl().encodedPath()).isEqualTo(path); + assertThat(request.getHeader("Authorization")).isEqualTo("Bearer test-token"); + assertThat(request.getHeader("X-Catalog-Context")).isEqualTo("configured"); + return request; + } +}
