This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new bbae07d375 [rest] Add semantic view definition management to REST
Catalog (#9848)
bbae07d375 is described below
commit bbae07d37545cf2cf65b397007fa68c387bb521f
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;
+ }
+}