Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions converters/polaris/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# OSI Polaris Converter

A two-way converter between [OSI semantic models](../../core-spec/spec.md) and [Apache Polaris](https://polaris.apache.org/) catalogs.

Apache Polaris is an open-source catalog for Apache Iceberg. This converter communicates with Polaris via the Iceberg REST Catalog API to import catalog metadata into OSI format and export OSI models back into Polaris.

## Building

```bash
mvn clean package
```

Requires Java 11+.

## Usage

### Import (Polaris → OSI)

Reads all namespaces and tables from a Polaris catalog and generates an OSI YAML file.

```bash
java -jar target/osi-polaris-converter-0.1.0-SNAPSHOT.jar import \
--url http://localhost:8181 \
--catalog my_catalog \
--client-id <client-id> \
--client-secret <client-secret> \
-o output.yaml
```

Each Polaris namespace becomes a separate OSI semantic model containing datasets for every table in that namespace.

### Export (OSI → Polaris)

Reads an OSI YAML file and creates namespaces and Iceberg tables in a Polaris catalog.

```bash
java -jar target/osi-polaris-converter-0.1.0-SNAPSHOT.jar export \
--url http://localhost:8181 \
--catalog my_catalog \
--client-id <client-id> \
--client-secret <client-secret> \
model.yaml
```

Each OSI semantic model becomes a Polaris namespace, and each dataset becomes an Iceberg table.

### Options

| Option | Description |
|--------|-------------|
| `--url URL` | Polaris server URL (required) |
| `--catalog CATALOG` | Catalog name (required) |
| `--client-id ID` | OAuth2 client ID |
| `--client-secret SECRET` | OAuth2 client secret |
| `--token TOKEN` | Pre-existing bearer token (alternative to client credentials) |
| `-o FILE` | Output file for import mode (default: stdout) |

## Mapping Reference

### Import (Polaris → OSI)

| Polaris / Iceberg | OSI |
|-------------------|-----|
| Namespace | `semantic_model` (name, description) |
| Table | `dataset` (name) |
| Table location (`catalog.namespace.table`) | `dataset.source` |
| Schema fields | `field` with `ANSI_SQL` dialect expression |
| `identifier-field-ids` | `dataset.primary_key` |
| Temporal types (`timestamp`, `timestamptz`, `date`, `time`) | `field.dimension.is_time: true` |
| Table properties | `dataset.custom_extensions` (vendor: `COMMON`) |

### Export (OSI → Polaris)

| OSI | Polaris / Iceberg |
|-----|-------------------|
| `semantic_model` | Namespace |
| `dataset` | Table |
| `dataset.source` | Stored in table property `osi.source` |
| `dataset.primary_key` | `identifier-field-ids` |
| `field` | Schema column |
| `field.dimension.is_time: true` | `timestamptz` type |
| `dataset.description` | Table property `comment` |

### Type Inference (Export)

Since OSI fields are expression-based and don't carry explicit types, the exporter infers Iceberg types using:

1. **Round-trip hints** — if a field description starts with `Iceberg type:` (produced by the importer), that type is used directly.
2. **Time dimensions** — fields with `dimension.is_time: true` map to `timestamptz`.
3. **Name conventions** — `*_id` → `long`, `*_date` → `date`, `*_at`/`*_timestamp` → `timestamptz`, `*_amount`/`*_price` → `decimal(18,2)`, `*_count`/`quantity` → `int`, `is_*`/`has_*` → `boolean`.
4. **Default** — `string`.

## Architecture

```
┌───────────────┐
│ Polaris REST │
│ Catalog │
└──────┬────────┘
┌──────┴────────┐
│ PolarisClient │ Iceberg REST API
└──────┬────────┘
┌────────────┼────────────┐
│ │
┌────────┴────────┐ ┌─────────┴─────────┐
│ PolarisImporter │ │ PolarisExporter │
│ (Polaris → OSI) │ │ (OSI → Polaris) │
└────────┬────────┘ └─────────┬─────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│OsiYamlGen. │ │OsiModelParser│
└─────────────┘ └─────────────┘
```

## Dependencies

- [SnakeYAML 2.2](https://bitbucket.org/snakeyaml/snakeyaml/) — OSI YAML parsing and generation
- [Jackson Databind 2.17](https://github.com/FasterXML/jackson-databind) — JSON handling for Polaris REST API
- [JUnit 5](https://junit.org/junit5/) — testing

## License

Apache License 2.0 — see [LICENSE](../../LICENSE).
64 changes: 64 additions & 0 deletions converters/polaris/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.osi</groupId>
<artifactId>osi-polaris-converter</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<name>OSI Polaris Converter</name>
<description>Converts between OSI semantic models and Apache Polaris catalog metadata</description>

<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<snakeyaml.version>2.2</snakeyaml.version>
<jackson.version>2.17.0</jackson.version>
<junit.version>5.10.2</junit.version>
</properties>

<dependencies>
<!-- YAML parsing/generation -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>

<!-- JSON parsing for Polaris REST API responses -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>org.osi.converter.polaris.OsiPolarisConverter</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package org.osi.converter.polaris;

import org.osi.converter.polaris.model.OsiModel;
import org.osi.converter.polaris.model.OsiModel.*;
import org.yaml.snakeyaml.Yaml;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* Parses an OSI YAML file into an {@link OsiModel}.
*/
public class OsiModelParser {

/**
* Parse an OSI YAML file from the given path.
*/
public OsiModel parse(Path yamlPath) throws IOException {
try (InputStream is = Files.newInputStream(yamlPath)) {
return parse(is);
}
}

/**
* Parse an OSI YAML file from an input stream.
*/
@SuppressWarnings("unchecked")
public OsiModel parse(InputStream is) {
Yaml yaml = new Yaml();
Map<String, Object> root = yaml.load(is);

OsiModel model = new OsiModel();
model.setVersion((String) root.get("version"));

List<Map<String, Object>> smList = (List<Map<String, Object>>) root.get("semantic_model");
if (smList == null) {
return model;
}

List<SemanticModel> semanticModels = new ArrayList<>();
for (Map<String, Object> smMap : smList) {
semanticModels.add(parseSemanticModel(smMap));
}
model.setSemanticModels(semanticModels);
return model;
}

@SuppressWarnings("unchecked")
private SemanticModel parseSemanticModel(Map<String, Object> map) {
SemanticModel sm = new SemanticModel();
sm.setName((String) map.get("name"));
sm.setDescription((String) map.get("description"));

// Datasets
List<Map<String, Object>> dsList = (List<Map<String, Object>>) map.get("datasets");
if (dsList != null) {
List<Dataset> datasets = new ArrayList<>();
for (Map<String, Object> dsMap : dsList) {
datasets.add(parseDataset(dsMap));
}
sm.setDatasets(datasets);
}

// Relationships
List<Map<String, Object>> relList = (List<Map<String, Object>>) map.get("relationships");
if (relList != null) {
List<Relationship> relationships = new ArrayList<>();
for (Map<String, Object> relMap : relList) {
relationships.add(parseRelationship(relMap));
}
sm.setRelationships(relationships);
}

// Metrics
List<Map<String, Object>> metricList = (List<Map<String, Object>>) map.get("metrics");
if (metricList != null) {
List<Metric> metrics = new ArrayList<>();
for (Map<String, Object> mMap : metricList) {
metrics.add(parseMetric(mMap));
}
sm.setMetrics(metrics);
}

return sm;
}

@SuppressWarnings("unchecked")
private Dataset parseDataset(Map<String, Object> map) {
Dataset ds = new Dataset();
ds.setName((String) map.get("name"));
ds.setSource((String) map.get("source"));
ds.setDescription((String) map.get("description"));

List<String> pk = (List<String>) map.get("primary_key");
if (pk != null) {
ds.setPrimaryKey(new ArrayList<>(pk));
}

List<List<String>> uniqueKeys = (List<List<String>>) map.get("unique_keys");
if (uniqueKeys != null) {
ds.setUniqueKeys(uniqueKeys);
}

List<Map<String, Object>> fieldList = (List<Map<String, Object>>) map.get("fields");
if (fieldList != null) {
List<Field> fields = new ArrayList<>();
for (Map<String, Object> fMap : fieldList) {
fields.add(parseField(fMap));
}
ds.setFields(fields);
}

List<Map<String, Object>> extList = (List<Map<String, Object>>) map.get("custom_extensions");
if (extList != null) {
List<CustomExtension> extensions = new ArrayList<>();
for (Map<String, Object> extMap : extList) {
CustomExtension ext = new CustomExtension();
ext.setVendorName((String) extMap.get("vendor_name"));
ext.setData((String) extMap.get("data"));
extensions.add(ext);
}
ds.setCustomExtensions(extensions);
}

return ds;
}

@SuppressWarnings("unchecked")
private Field parseField(Map<String, Object> map) {
Field field = new Field();
field.setName((String) map.get("name"));
field.setDescription((String) map.get("description"));

// Dimension
Map<String, Object> dim = (Map<String, Object>) map.get("dimension");
if (dim != null) {
Object isTime = dim.get("is_time");
field.setTime(Boolean.TRUE.equals(isTime));
}

// Expressions
field.setExpressions(parseDialectExpressions(map));
return field;
}

@SuppressWarnings("unchecked")
private Relationship parseRelationship(Map<String, Object> map) {
Relationship rel = new Relationship();
rel.setName((String) map.get("name"));
rel.setFrom((String) map.get("from"));
rel.setTo((String) map.get("to"));

List<String> fromCols = (List<String>) map.get("from_columns");
if (fromCols != null) {
rel.setFromColumns(new ArrayList<>(fromCols));
}
List<String> toCols = (List<String>) map.get("to_columns");
if (toCols != null) {
rel.setToColumns(new ArrayList<>(toCols));
}
return rel;
}

@SuppressWarnings("unchecked")
private Metric parseMetric(Map<String, Object> map) {
Metric metric = new Metric();
metric.setName((String) map.get("name"));
metric.setDescription((String) map.get("description"));
metric.setExpressions(parseDialectExpressions(map));
return metric;
}

@SuppressWarnings("unchecked")
private List<DialectExpression> parseDialectExpressions(Map<String, Object> map) {
List<DialectExpression> result = new ArrayList<>();
Map<String, Object> exprBlock = (Map<String, Object>) map.get("expression");
if (exprBlock == null) {
return result;
}
List<Map<String, Object>> dialects = (List<Map<String, Object>>) exprBlock.get("dialects");
if (dialects == null) {
return result;
}
for (Map<String, Object> d : dialects) {
String dialect = (String) d.get("dialect");
Object exprValue = d.get("expression");
String expression = exprValue != null ? exprValue.toString() : null;
result.add(new DialectExpression(dialect, expression));
}
return result;
}
}
Loading