blob: 7322ddb41ea039149d288772396bc544e4737752 [file]
# 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.
from enum import Enum
from logging import getLogger
from pathlib import Path
from typing import Dict, List, Literal, Optional # TODO: move to the TYPE_CHECKING block
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from typing_extensions import Self
logger = getLogger("graphar_cli")
# TODO: move them to constants.py
DEFAULT_FILE_TYPE = "parquet"
DEFAULT_ADJ_LIST_TYPE = "ordered_by_source"
DEFAULT_REGULAR_SEPARATOR = "_"
DEFAULT_VALIDATE_LEVEL = "weak"
DEFAULT_VERSION = "gar/v1"
class FileType(str, Enum):
parquet = "parquet"
csv = "csv"
orc = "orc"
json = "json"
class GraphArConfig(BaseModel):
path: str
name: str
vertex_chunk_size: Optional[int] = 100
edge_chunk_size: Optional[int] = 1024
file_type: FileType = DEFAULT_FILE_TYPE
adj_list_type: Literal[
"ordered_by_source", "ordered_by_dest", "unordered_by_source", "unordered_by_dest"
] = DEFAULT_ADJ_LIST_TYPE
validate_level: Literal["no", "weak", "strong"] = DEFAULT_VALIDATE_LEVEL
version: Optional[str] = DEFAULT_VERSION
@field_validator("path")
def check_path(cls, v):
path = Path(v).resolve().absolute()
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
elif any(path.iterdir()):
msg = f"Warning: Path {v} already exists and contains files."
logger.warning(msg)
return v
class Property(BaseModel):
name: str
data_type: Literal["bool", "int32", "int64", "float", "double", "string", "date", "timestamp"]
is_primary: bool = False
nullable: Optional[bool] = None
@model_validator(mode="after")
def check_nullable(self) -> Self:
if self.is_primary and self.nullable:
msg = f"Primary key `{self.name}` must not be nullable."
raise ValueError(msg)
if self.is_primary:
self.nullable = False
elif self.nullable is None:
self.nullable = True
return self
class PropertyGroup(BaseModel):
properties: List[Property]
file_type: Optional[FileType] = None
@field_validator("properties")
def check_properties_length(cls, v):
if len(v) == 0:
msg = "properties must not be empty."
raise ValueError(msg)
return v
class Source(BaseModel):
file_type: Optional[FileType] = None
path: str
delimiter: str = ","
columns: Dict[str, str]
@field_validator("path")
def check_path(cls, v):
path = Path(v).resolve().absolute()
if not path.is_file():
msg = f"'{path}' is not a file."
raise ValueError(msg)
return v
@field_validator("delimiter")
def check_delimiter(cls, v):
if len(v) != 1:
msg = "delimiter must be a single character."
raise ValueError(msg)
return v
@model_validator(mode="after")
def check_file_type(self) -> Self:
if not self.file_type:
file_type = Path(self.path).suffix.removeprefix(".")
if file_type == "":
msg = f"File {self.path} has no file type suffix"
raise ValueError(msg)
if file_type not in FileType.__members__:
msg = f"Invalid file type '{file_type}'"
raise ValueError(msg)
self.file_type = file_type
return self
class Vertex(BaseModel):
type: str
labels: List[str] = []
chunk_size: Optional[int] = None
validate_level: Optional[Literal["no", "weak", "strong"]] = None
prefix: Optional[str] = None
property_groups: List[PropertyGroup]
sources: List[Source]
@field_validator("property_groups")
def check_property_groups_length(cls, v):
if len(v) == 0:
msg = "property_groups must not be empty."
raise ValueError(msg)
return v
@field_validator("sources")
def check_sources_length(cls, v):
if len(v) == 0:
msg = "sources must not be empty."
raise ValueError(msg)
return v
@model_validator(mode="after")
def check_vertex_prefix(self) -> Self:
prefix = self.prefix
type = self.type
if not prefix:
self.prefix = f"vertex/{type}/"
return self
class AdjList(BaseModel):
ordered: bool
aligned_by: Literal["src", "dst"]
file_type: Optional[FileType] = None
class Edge(BaseModel):
edge_type: str
src_type: str
src_prop: str
dst_type: str
dst_prop: str
chunk_size: Optional[int] = None
validate_level: Optional[Literal["no", "weak", "strong"]] = None
adj_lists: List[AdjList] = []
property_groups: List[PropertyGroup] = []
sources: List[Source]
prefix: Optional[str] = None
@field_validator("sources")
def check_sources_length(cls, v):
if len(v) == 0:
msg = "sources must not be empty."
raise ValueError(msg)
return v
@model_validator(mode="after")
def check_prefix(self) -> Self:
prefix = self.prefix
src_type = self.src_type
edge_type = self.edge_type
dst_type = self.dst_type
if not prefix:
self.prefix = (
f"edge/{src_type}"
f"{DEFAULT_REGULAR_SEPARATOR}{edge_type}"
f"{DEFAULT_REGULAR_SEPARATOR}{dst_type}/"
)
return self
class ImportSchema(BaseModel):
vertices: List[Vertex]
edges: List[Edge]
@field_validator("vertices")
def check_property_groups_length(cls, v):
if len(v) == 0:
msg = "vertices must not be empty."
raise ValueError(msg)
return v
class ImportConfig(BaseModel):
model_config = ConfigDict(use_enum_values=True)
graphar: GraphArConfig
import_schema: ImportSchema
@model_validator(mode="after")
def check_none_types(self) -> Self:
for vertex in self.import_schema.vertices:
if vertex.chunk_size is None:
vertex.chunk_size = self.graphar.vertex_chunk_size
if vertex.validate_level is None:
vertex.validate_level = self.graphar.validate_level
for property_group in vertex.property_groups:
if property_group.file_type is None:
property_group.file_type = self.graphar.file_type
for edge in self.import_schema.edges:
if edge.chunk_size is None:
edge.chunk_size = self.graphar.edge_chunk_size
if edge.validate_level is None:
edge.validate_level = self.graphar.validate_level
if len(edge.adj_lists) == 0:
if self.graphar.adj_list_type == "ordered_by_source":
edge.adj_lists.append(
AdjList(ordered=True, aligned_by="src", file_type=self.graphar.file_type)
)
elif self.graphar.adj_list_type == "ordered_by_dest":
edge.adj_lists.append(
AdjList(ordered=True, aligned_by="dst", file_type=self.graphar.file_type)
)
elif self.graphar.adj_list_type == "unordered_by_source":
edge.adj_lists.append(
AdjList(ordered=False, aligned_by="src", file_type=self.graphar.file_type)
)
elif self.graphar.adj_list_type == "unordered_by_dest":
edge.adj_lists.append(
AdjList(ordered=False, aligned_by="dst", file_type=self.graphar.file_type)
)
else:
msg = f"Invalid adj_list_type '{self.graphar.adj_list_type}'"
raise ValueError(msg)
for adj_list in edge.adj_lists:
if adj_list.file_type is None:
adj_list.file_type = self.graphar.file_type
for property_group in edge.property_groups:
if property_group.file_type is None:
property_group.file_type = self.graphar.file_type
return self