From 0d1211e785d5bef152ab777026c36f7992231764 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Fri, 26 Mar 2021 06:23:21 +1000 Subject: [PATCH] Add package files, basic SQL objects. Table is in an incomplete state, will need to complete later. --- .flake8 | 20 ++ everstone/__init__.py | 3 + everstone/bases.py | 14 ++ everstone/database.py | 114 ++++++++++ everstone/exceptions.py | 10 + everstone/sql/__init__.py | 0 everstone/sql/aggregates.py | 109 ++++++++++ everstone/sql/column.py | 64 ++++++ everstone/sql/comparisons.py | 95 +++++++++ everstone/sql/constraints.py | 127 +++++++++++ everstone/sql/schema.py | 72 +++++++ everstone/sql/table.py | 60 ++++++ everstone/sql/types.py | 393 +++++++++++++++++++++++++++++++++++ poetry.lock | 240 +++++++++++++++++++++ pyproject.toml | 24 +++ 15 files changed, 1345 insertions(+) create mode 100644 .flake8 create mode 100644 everstone/__init__.py create mode 100644 everstone/bases.py create mode 100644 everstone/database.py create mode 100644 everstone/exceptions.py create mode 100644 everstone/sql/__init__.py create mode 100644 everstone/sql/aggregates.py create mode 100644 everstone/sql/column.py create mode 100644 everstone/sql/comparisons.py create mode 100644 everstone/sql/constraints.py create mode 100644 everstone/sql/schema.py create mode 100644 everstone/sql/table.py create mode 100644 everstone/sql/types.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..917441a --- /dev/null +++ b/.flake8 @@ -0,0 +1,20 @@ +[flake8] +max-line-length=120 +docstring-convention=all +import-order-style=pycharm +application_import_names=everstone +exclude=.cache,.venv,.git,.idea,unfinished +suppress-none-returning=true +ignore= + B311,W503,E226,S311,T000 + # Missing Docstrings + D100,D104,D105,D107, + # Docstring Whitespace + D203,D212,D214,D215, + # Docstring Quotes + D301,D302, + # Docstring Content + D400,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D412,D413,D414,D416,D417 + # Type Annotations + ANN002,ANN003,ANN101,ANN102,ANN204,ANN206 +per-file-ignores=everstone/__init__.py:F401 diff --git a/everstone/__init__.py b/everstone/__init__.py new file mode 100644 index 0000000..db6286a --- /dev/null +++ b/everstone/__init__.py @@ -0,0 +1,3 @@ +from .database import Database + +db = Database("__default__") diff --git a/everstone/bases.py b/everstone/bases.py new file mode 100644 index 0000000..87749bd --- /dev/null +++ b/everstone/bases.py @@ -0,0 +1,14 @@ +import typing as t + + +class LimitInstances: + """Ensures only a single instance exists per name, returning old ones if existing.""" + + __instances__: t.Dict[str, object] = dict() + + def __new__(cls, name: str, *args, **kwds): + """Set the class to a single instance per name.""" + instance = cls.__instances__.get(name) + if instance is None: + cls.__instances__[name] = instance = object.__new__(cls) + return instance diff --git a/everstone/database.py b/everstone/database.py new file mode 100644 index 0000000..e2fe882 --- /dev/null +++ b/everstone/database.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import json +import logging +import typing as t + +import asyncpg +import sqlparse + +from .bases import LimitInstances +from .sql import types +from .sql.schema import Schema +from .sql.table import Table + +log = logging.getLogger(__name__) + + +class Database(LimitInstances): + """Represents a database.""" + + def __init__(self, name: str): + self.name = name + self.user: t.Optional[str] = None + self.url: t.Optional[str] = None + + self.pool: t.Optional[asyncpg.Pool] = None + + self.type = types + self.schemas: t.Set[Schema] = set() + + self._mock = False + self._prepared = False + + @classmethod + def connect(cls, name: str, user: str, password: str, *, host: str = "localhost", port: int = 5432) -> Database: + """Establish the connection URL and name for the database, returning the instance representing it.""" + if len(cls.__instances__) == 1: + db = cls.__instances__["__default__"] + cls.__instances__[name] = db + db.name = name + db.user = user + db.url = f"postgres://{user}:{password}@{host}:{port}/{name}" + # noinspection PyTypeChecker + return db + + return Database(name) + + def __call__(self, name: str) -> Database: + """Return the instance representing the given database name.""" + return Database(name) + + def __str__(self): + """Return the URL representation of the given database instance, if set.""" + return self.url or self.name + + def __repr__(self): + if self.user: + return f"" + else: + return f"" + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other: t.Any): + if isinstance(other, Database): + return str(self) == str(self) + return False + + async def create_pool(self): + """Create the asyncpg connection pool for this database connection to use.""" + if self.pool: + self.pool.close() + self.pool = await asyncpg.create_pool(self.url, init=self._enable_json) + + @staticmethod + async def _enable_json(conn: asyncpg.Connection): + await conn.set_type_codec("jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") + await conn.set_type_codec("json", encoder=json.dumps, decoder=json.loads, schema="pg_catalog") + + async def prepare(self): + """Prepare all child objects for this database.""" + for schema in self.schemas: + await schema.prepare() + self._prepared = True + + def disable_execution(self): + """Don't execute SQL statements, divert them to console output instead.""" + self._mock = True + + def enable_execution(self): + """Restore normal SQL execution status instead of diverting statements to console output.""" + self._mock = False + + async def close(self): + """Close the asyncpg connection pool for this database.""" + if self.pool: + await self.pool.close() + + async def execute(self, sql: str, *args, timeout: t.Optional[float] = None) -> str: + """Execute an SQL statement.""" + if self._mock: + pretty_sql = sqlparse.parse(sql) + print(pretty_sql, *args) + return pretty_sql + return await self.pool.execute(sql, *args, timeout=timeout) + + def Schema(self, name: str) -> Schema: + """Return a bound Schema for this database.""" + return Schema(name, self) + + def Table(self, name: str) -> Table: + """Return a bound Table for the public schema on this database.""" + return Table(name, self) diff --git a/everstone/exceptions.py b/everstone/exceptions.py new file mode 100644 index 0000000..3f61b70 --- /dev/null +++ b/everstone/exceptions.py @@ -0,0 +1,10 @@ +class DBError(Exception): + """Base exception for database errors.""" + + +class SchemaError(DBError): + """Exception for schema-specific errors.""" + + +class ResponseError(DBError): + """Exception for database response errors.""" diff --git a/everstone/sql/__init__.py b/everstone/sql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/everstone/sql/aggregates.py b/everstone/sql/aggregates.py new file mode 100644 index 0000000..bbd495d --- /dev/null +++ b/everstone/sql/aggregates.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import typing as t + +from . import comparisons, table + +if t.TYPE_CHECKING: + from .column import Column + + +class Aggregate(comparisons.Comparable): + """Represents an aggregate SQL function.""" + + name: str + + def __init__(self, column: t.Optional[Column, str], alias: t.Optional[str] = None): + self.column = column + self.alias = alias + + @property + def sql(self) -> str: + """Generates the SQL statement representing the aggregate function.""" + if self.alias: + return f"{self.name}({self.column}) AS {self.alias}" + return f"{self.name}({self.column})" + + @property + def distinct(self) -> Aggregate: + """Sets column values to be DISTINCT when used in the aggregate function.""" + self.column = f"DISTINCT {self.column}" + return self + + def as_(self, alias: str) -> Aggregate: + """Sets an alias name to represent the result of the aggregate function.""" + self.alias = alias + return self + + def __repr__(self) -> str: + if self.alias: + return f"<{self.__class__.__name__} alias={self.alias} column={self.column} sql='{self.sql}'>" + return f"<{self.__class__.__name__} column={self.column} sql='{self.sql}'>" + + def __str__(self) -> str: + return self.sql + + +class Avg(Aggregate): + """Computes the average of all non-null input values.""" + + name = "avg" + + +class BitAnd(Aggregate): + """Computes the bitwise AND of all non-null input values.""" + + name = "bit_and" + + +class BitOr(Aggregate): + """Computes the bitwise OR of all non-null input values.""" + + name = "bit_or" + + +class BoolAnd(Aggregate): + """Returns TRUE if all non-null input values are TRUE, otherwise FALSE.""" + + name = "bool_and" + + +class BoolOr(Aggregate): + """Returns TRUE if any non-null input value is TRUE, otherwise FALSE.""" + + name = "bool_or" + + +class Count(Aggregate): + """Computes the number of input rows, counting only non-nulls if a column is specified.""" + + name = "count" + + def __init__(self, value: t.Optional[Column, table.Table, str], alias: t.Optional[str] = None): + if isinstance(value, table.Table): + super().__init__(f"{value}.*" if value else "*", alias) + else: + super().__init__(f"{value}", alias) + + @classmethod + def all(cls, alias: t.Optional = None) -> Count: + """Computes the number of all input rows in a table.""" + return cls("*", alias) + + +class Max(Aggregate): + """Computes the maximum of the non-null input values.""" + + name = "max" + + +class Min(Aggregate): + """Computes the minimum of the non-null input values.""" + + name = "min" + + +class Sum(Aggregate): + """Computes the sum of the non-null input values.""" + + name = "sum" diff --git a/everstone/sql/column.py b/everstone/sql/column.py new file mode 100644 index 0000000..fbb515b --- /dev/null +++ b/everstone/sql/column.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import typing as t + +from . import aggregates, comparisons + +if t.TYPE_CHECKING: + from .constraints import Constraint + from .types import SQLType + from .table import Table + + +class Column(comparisons.Comparable): + """Reprents an SQL column.""" + + def __init__(self, name: str, type: SQLType, constraint: t.Optional[Constraint] = None): + self.name = name + self.type = type + self.constraint = constraint + self.table: t.Optional[Table] = None + + @property + def definition(self) -> str: + """Return the SQL definition for this column.""" + sql = f"{self.name} {self.type}" + if self.constraint: + sql += f" {self.constraint}" + return sql + + def bind_table(self, table: Table) -> Column: + """Binds a table to this column.""" + self.table = table + return self + + def __str__(self) -> str: + if self.table: + return f"{self.table}.{self.name}" + else: + return self.name + + @property + def avg(self) -> aggregates.Avg: + """Return the avg aggreate for this column.""" + return aggregates.Avg(self) + + @property + def count(self) -> aggregates.Count: + """Return the count aggreate for this column.""" + return aggregates.Count(self) + + @property + def max(self) -> aggregates.Max: + """Return the max aggreate for this column.""" + return aggregates.Max(self) + + @property + def min(self) -> aggregates.Min: + """Return the min aggreate for this column.""" + return aggregates.Min(self) + + @property + def sum(self) -> aggregates.Sum: + """Return the sum aggreate for this column.""" + return aggregates.Sum(self) diff --git a/everstone/sql/comparisons.py b/everstone/sql/comparisons.py new file mode 100644 index 0000000..0143886 --- /dev/null +++ b/everstone/sql/comparisons.py @@ -0,0 +1,95 @@ +import abc +import typing as t + + +class Comparable(abc.ABC): + """Base class to define an SQL object as able to use SQL comparison operations.""" + + @staticmethod + def _sql_value(value: t.Any) -> str: + """Adjusts a given value into an appropriate representation for SQL statements.""" + if value is None: + return "NULL" + elif isinstance(value, str): + return f"'{value}'" + elif isinstance(value, bool): + return "TRUE" if value else "FALSE" + else: + return f"{value}" + + def __lt__(self, value: t.Any) -> str: + """Evaluate if less than a value.""" + value = self._sql_value(value) + return f"{self} < {value}" + + def __le__(self, value: t.Any) -> str: + """Evaluate if less than or equal to a value.""" + value = self._sql_value(value) + return f"{self} <= {value}" + + def __eq__(self, value: t.Any) -> str: + """Evaluate if equal to a value.""" + value = self._sql_value(value) + return f"{self} = {value}" + + def __ne__(self, value: t.Any) -> str: + """Evaluate if not equal to a value.""" + value = self._sql_value(value) + return f"{self} <> {value}" + + def __gt__(self, value: t.Any) -> str: + """Evaluate if greater than a value.""" + value = self._sql_value(value) + return f"{self} > {value}" + + def __ge__(self, value: t.Any) -> str: + """Evaluate if greater than or equal to a value.""" + value = self._sql_value(value) + return f"{self} >= {value}" + + def like(self, value: t.Any) -> str: + """Evaluate if like a value.""" + value = self._sql_value(value) + return f"{self} LIKE {value}" + + def not_like(self, value: t.Any) -> str: + """Evaluate if not like a value.""" + value = self._sql_value(value) + return f"{self} NOT LIKE {value}" + + def ilike(self, value: t.Any) -> str: + """Evaluate if like a value, ignoring case.""" + value = self._sql_value(value) + return f"{self} ILIKE {value}" + + def not_ilike(self, value: t.Any) -> str: + """Evaluate if not like a value, ignoring case.""" + value = self._sql_value(value) + return f"{self} NOT ILIKE {value}" + + def between(self, minvalue: t.Any, maxvalue: t.Any) -> str: + """Evaluate if between two values.""" + minvalue = self._sql_value(minvalue) + maxvalue = self._sql_value(maxvalue) + return f"{self} BETWEEN {minvalue} AND {maxvalue}" + + def not_between(self, minvalue: t.Any, maxvalue: t.Any) -> str: + """Evaluate if not between two values.""" + minvalue = self._sql_value(minvalue) + maxvalue = self._sql_value(maxvalue) + return f"{self} NOT BETWEEN {minvalue} AND {maxvalue}" + + def is_(self, value: t.Any) -> str: + """Evaluate if is a value.""" + value = self._sql_value(value) + return f"{self} IS {value}" + + def is_not(self, value: t.Any) -> str: + """Evaluate if is not a value.""" + value = self._sql_value(value) + return f"{self} IS NOT {value}" + + def in_(self, value: t.Any) -> str: + """Evaluate if in a value.""" + value = self._sql_value(value) + return f"{self} IN {value}" diff --git a/everstone/sql/constraints.py b/everstone/sql/constraints.py new file mode 100644 index 0000000..4863bf5 --- /dev/null +++ b/everstone/sql/constraints.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import abc +import typing as t + +from .column import Column + +if t.TYPE_CHECKING: + from .table import Table + + +class ConstraintMeta(abc.ABCMeta): + """Metaclass to define behaviour of non-initialised constraint classes.""" + + sql: str + + def __repr__(self): + return f'<{self.__name__} sql="{self.sql}">' + + def __str__(self): + return self.sql + + def __eq__(self, other: t.Any): + if isinstance(other, ConstraintMeta) or isinstance(type(other), ConstraintMeta): + return self.sql == other.sql + return False + + def named(cls, name: str) -> NamedConstraint: + """Returns this constraint as a named constraint.""" + return NamedConstraint(cls, name) + + def columns(cls, *columns: Column) -> CompositeConstraint: + """Returns this constraint as a composite constraint, using multiple columns.""" + return CompositeConstraint(cls, *columns) + + +class Constraint(metaclass=ConstraintMeta): + """Base class representing an SQL constraint.""" + + sql: str + + def __init__(self): + self.named = self._named + self.columns = self._columns + + def __repr__(self): + return f'<{self.__class__.__name__} sql="{self.sql}">' + + def __str__(self): + return self.sql + + def __eq__(self, other: t.Any): + if isinstance(other, ConstraintMeta) or isinstance(type(other), ConstraintMeta): + return self.sql == other.sql + return False + + def _named(self, name: str) -> NamedConstraint: + """Instance-specific implementation of Constraint.named.""" + return NamedConstraint(self, name) + + def _columns(self, *columns: Column) -> CompositeConstraint: + """Instance-specific implementation of Constraint.columns.""" + return CompositeConstraint(self, *columns) + + +class NamedConstraint(Constraint): + """Composite class representing a named SQL constraint.""" + + def __init__(self, constraint: t.Union[Constraint, ConstraintMeta], name: str): + self.columns = self._columns + self.constraint = constraint + self.name = name + self.sql = f"CONSTRAINT {self.name} {self.constraint}" + + +class CompositeConstraint(Constraint): + """Composite class representing an SQL constraint that uses more than one column.""" + + def __init__(self, constraint: t.Union[Constraint, ConstraintMeta], *columns: t.Union[Column, str]): + self.constraint = constraint + self.columns = columns + cols = ", ".join(c.name if isinstance(c, Column) else c for c in columns) + self.sql = f"{constraint.sql} ({cols})" + + +class Check(Constraint): + """Represents a CHECK SQL constraint.""" + + sql: str + + def __init__(self, expression: str, *, name: t.Optional[str] = None): + super().__init__() + self.name = name + self.expression = expression + if name: + self.sql = f"CONSTRAINT {self.name} CHECK ({expression})" + else: + self.sql = f"CHECK ({expression})" + + +class NotNull(Constraint): + """Represents a NOT NULL SQL constraint.""" + + sql = "NOT NULL" + + +class Unique(Constraint): + """Represents a UNIQUE SQL constraint.""" + + sql = "UNIQUE" + + +class PrimaryKey(Constraint): + """Represents a PRIMARY KEY SQL constraint.""" + + sql = "PRIMARY KEY" + + +class ForeignKey(Constraint): + """Represents a foreign key SQL constraint using REFERENCES.""" + + sql: str + + def __init__(self, table: t.Union[Table, str], column: t.Union[Column, str]): + self.table = table + self.column = column.name if isinstance(column, Column) else column + self.sql = f"REFERENCES {self.table} ({self.column})" diff --git a/everstone/sql/schema.py b/everstone/sql/schema.py new file mode 100644 index 0000000..a7a9932 --- /dev/null +++ b/everstone/sql/schema.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import typing as t + +from . import table as tbl +from ..bases import LimitInstances + +if t.TYPE_CHECKING: + from everstone.database import Database + + +class Schema(LimitInstances): + """Represents a database schema.""" + + def __init__(self, name: str, database: Database): + self.name = name + self.db: Database = database + self.tables: t.Set[tbl.Table] = set() + self._exists = None + + def __repr__(self): + return f"" + + def __str__(self): + return self.name + + async def prepare(self): + """Ensure the schema exists in the database and prepare all child tables.""" + await self.create(if_exists=False) + for table in self.tables: + await table.prepare() + + @property + def exists(self) -> t.Optional[bool]: + """Returns True if Schema created or False if Schema dropped.""" + return self._exists + + def add_table(self, table: tbl.Table) -> Schema: + """Add a table under this schema.""" + self.tables.add(table) + return self + + async def rename(self, name: str) -> str: + """Alter the name of this schema.""" + sql = "ALTER SCHEMA $1 RENAME TO $2" + result = await self.db.pool.execute(sql, self.name, name) + del self.__instances__[self.name] + self.__instances__[name] = self + return result + + async def create(self, *, if_exists: bool = True) -> str: + """Create the schema on the database.""" + if if_exists: + sql = "CREATE SCHEMA $1;" + else: + sql = "CREATE SCHEMA IF NOT EXISTS $1;" + results = await self.db.execute(sql, self.name) + self._exists = True + return results + + async def drop(self, *, if_exists: bool = False, cascade: bool = False) -> str: + """Drop the schema from the database.""" + sql = "DROP SCHEMA {exists}$1{cascade};" + exists = "IF EXISTS " if if_exists else "" + cascade = " CASCADE" if cascade else "" + results = await self.db.execute(sql.format(exists=exists, cascade=cascade), self.name) + self._exists = False + return results + + def Table(self, name: str) -> tbl.Table: + """Return a Table isntance bound to this Schema.""" + return tbl.Table(name, self) diff --git a/everstone/sql/table.py b/everstone/sql/table.py new file mode 100644 index 0000000..b10419a --- /dev/null +++ b/everstone/sql/table.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import typing as t + +from . import column +from .. import database + +if t.TYPE_CHECKING: + from .constraints import Constraint + from .schema import Schema + from .types import SQLType + + +class Table: + """Represents an SQL table.""" + + def __init__(self, name: str, schema: t.Union[Schema, database.Database]): + self.name = name + + if isinstance(schema, database.Database): + self.db: database.Database = schema + self.schema: Schema = self.db.Schema("public") + else: + self.db: database.Database = schema.db + self.schema: Schema = schema + + self.schema.add_table(self) + + @property + def full_name(self) -> str: + """Return the fully qualified name of the current table.""" + return f"{self.schema}.{self.name}" + + def __str__(self): + return self.full_name + + def __hash__(self): + return hash(self.full_name) + + def __eq__(self, other: t.Any): + if isinstance(other, Table): + return self.full_name == other.full_name + return False + + async def prepare(self): + """Ensure the table exists in the database.""" + await self.create(if_exists=False) + + async def create(self, if_exists: bool = False): + """Create the table in the database.""" + pass + + async def drop(self): + """Drop table from database.""" + sql = "DROP TABLE $1" + await self.db.execute(sql, self.full_name) + + def Column(self, name: str, type: SQLType, constraint: t.Optional[Constraint] = None) -> Column: + """Return a Column instance bound to this table.""" + return column.Column(name, type, constraint).bind_table(self) diff --git a/everstone/sql/types.py b/everstone/sql/types.py new file mode 100644 index 0000000..21a92ef --- /dev/null +++ b/everstone/sql/types.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import abc +import datetime +import decimal +import typing as t +import zoneinfo + +__all__ = ( + "Integer", "SmallInteger", "BigInteger", "Serial", "SmallSerial", "BigSerial", "Numeric", "Decimal", "Real", + "DoublePrecision", "Money", "Text", "ByteA", "Timestamp", "TimestampTZ", "Date", "Time", "Interval", "Boolean", + "JSON", "JSONB", "Array", "SQLType", +) + + +# region: Bases + +class SpecialValue(abc.ABC): + """Represents a special value specific to an SQL Type.""" + + def __init__(self, python_value: t.Any, sql_value: str): + self._py_value = python_value + self.sql = sql_value + + @property + def py(self) -> t.Any: + """Python representation of the special value.""" + if isinstance(self._py_value, t.Callable): + return self._py_value() + else: + return self._py_value + + def __repr__(self): + return f"SpecialValue({self.py}, '{self.sql})'" + + def __str__(self): + return self.sql + + def __eq__(self, other: t.Any): + if not isinstance(other, SpecialValue): + return False + return self.sql == other.sql and self._py_value == other._py_value + + +class SQLTypeMeta(abc.ABCMeta): + """Metaclass defining the behaviour of non-initialised SQLType classes.""" + + __types__ = dict() + py: t.Any + sql: str + + def __init__(cls, *_args, **_kwargs): + super().__init__(cls) + if cls.__name__ == "SQLType": + return + SQLTypeMeta.__types__[cls.py] = cls + + def __repr__(self): + classname = self.__name__ + return f'<{classname} python={self.py.__name__} sql="{self.sql}">' + + def __str__(self): + return self.sql + + def __eq__(self, other: t.Any): + if isinstance(other, SQLTypeMeta) or isinstance(type(other), SQLTypeMeta): + return self.sql == other.sql + return False + + +class SQLType(metaclass=SQLTypeMeta): + """Base class representing an SQL datatype.""" + + py: t.Any + sql: str + + def __init__(self): + pass + + def __repr__(self): + classname = self.__class__.__name__ + return f'<{classname} python={self.py.__name__} sql="{self.sql}">' + + def __str__(self): + return self.sql + + def __eq__(self, other: t.Any): + if isinstance(other, SQLTypeMeta) or isinstance(type(other), SQLTypeMeta): + return self.sql == other.sql + return False + + +# endregion + + +# region: Numeric Types + +class Integer(SQLType): + """ + Whole number from -32768 to +32767. + + Uses 4 bytes of storage. + """ + + py = int + sql = "INTEGER" + + +class SmallInteger(Integer): + """ + Whole number from -2147483648 to +2147483647. + + Uses 2 bytes of storage. + """ + + sql = "SMALLINT" + + +class BigInteger(Integer): + """ + Whole number from -9223372036854775808 to +9223372036854775807. + + Uses 8 bytes of storage. + """ + + sql = "BIGINT" + + +class Serial(Integer): + """ + Auto-incrementing number from 1 to 2147483647. + + Uses 4 bytes of storage. + """ + + sql = "SERIAL" + + +class SmallSerial(Serial): + """ + Auto-incrementing number from 1 to 32767. + + Uses 2 bytes of storage. + """ + + sql = "SMALLSERIAL" + + +class BigSerial(Serial): + """ + Auto-incrementing number from 1 to 9223372036854775807. + + Uses 8 bytes of storage. + """ + + sql = "BIGSERIAL" + + +class Numeric(SQLType): + """ + Precise decimal number with configurable precision and scale. + + Uses 3 to 8 bytes overhead and 2 bytes for every 4 decimal digits. + """ + + py = decimal.Decimal + sql = "NUMERIC" + + # special values + not_a_number = SpecialValue(decimal.Decimal("NaN"), "'NaN'") + + def __init__(self, precision: int, scale: int = 0): # noqa + self.precision = precision + self.scale = scale + self.sql = f"NUMERIC({precision}, {scale})" + + +class Decimal(Numeric): + """ + Precise decimal number with configurable precision and scale. + + Uses 3 to 8 bytes storage overhead and 2 bytes for every 4 decimal digits. + """ + + sql = "DECIMAL" + + +class Real(SQLType): + """ + Inexact floating-point number with a range of 1E-37 to 1E+37. + + Uses 4 bytes of storage. + """ + + py = float + sql = "REAL" + + # special values + not_a_number = SpecialValue(float("NaN"), "'NaN'") + infinity = SpecialValue(float("inf"), "'Infinity'") + negative_infinity = SpecialValue(float("-inf"), "'-Infinity'") + + +class DoublePrecision(Real): + """ + Inexact floating-point number with a range of 1E-307 to 1E+308. + + Uses 8 bytes of storage. + """ + + sql = "DOUBLE PRECISION" + + +class Money(SQLType): + """ + Currency amount with a fixed precision ranging from -92233720368547758.08 to +92233720368547758.07. + + Uses 8 bytes of storage. + """ + + py = str + sql = "MONEY" + + +# endregion + + +# region: String Types + +class Text(SQLType): + """ + Variable unlimited string. + + Uses 1 byte of storage overhead for strings under 126 bytes in length, or 4 bytes if over that length. + """ + + py = str + sql = "TEXT" + + +class ByteA(SQLType): + """ + Variable unlimited binary string. + + Uses 1 byte of storage overhead for strings under 126 bytes in length, or 4 bytes if over that length. + """ + + py = bytes + sql = "BYTEA" + + +# endregion + + +# region: DateTime Types + +class Timestamp(SQLType): + """ + Timezone naive datetime. + + Uses 8 bytes of storage. + """ + + py = datetime.datetime + sql = "TIMESTAMP" + + # special values + epoch = SpecialValue(datetime.datetime.utcfromtimestamp(0), "'Epoch'") + infinity = SpecialValue(datetime.datetime.max, "'Infinity'") + negative_infinity = SpecialValue(datetime.datetime.min, "'-Infinity'") + now = SpecialValue(datetime.datetime.now, "Now") + today = SpecialValue(datetime.datetime.today, "Today") + tomorrow = SpecialValue(lambda: datetime.datetime.today() + datetime.timedelta(days=1), "Tomorrow") + yesterday = SpecialValue(lambda: datetime.datetime.today() + datetime.timedelta(days=-1), "Yesterday") + + def __init__(self, precision: int): # noqa + self.precision = precision + self.sql = f"TIMESTAMP({precision})" + + +class TimestampTZ(Timestamp): + """ + Timezone aware datetime. + + Uses 8 bytes of storage. + """ + + sql = "TIMESTAMP WITH TIME ZONE" + + def __init__(self, precision: int): # noqa + self.precision = precision + self.sql = f"TIMESTAMP({precision}) WITH TIME ZONE" + + +class Date(SQLType): + """ + Date from 4713BC to 5874897AD. + + Uses 4 bytes of storage. + """ + + py = datetime.date + sql = "DATE" + + # special values + epoch = SpecialValue(datetime.datetime.utcfromtimestamp(0).date(), "'Epoch'") + infinity = SpecialValue(datetime.datetime.max.date(), "'Infinity'") + negative_infinity = SpecialValue(datetime.datetime.min.date(), "'-Infinity'") + now = SpecialValue(datetime.date.today, "Now") + today = SpecialValue(datetime.date.today, "Today") + tomorrow = SpecialValue(lambda: datetime.date.today() + datetime.timedelta(days=1), "Tomorrow") + yesterday = SpecialValue(lambda: datetime.date.today() + datetime.timedelta(days=-1), "Yesterday") + + +class Time(SQLType): + """ + Timezone naive time of day. + + Uses 8 bytes of storage. + """ + + py = datetime.time + sql = "TIME" + + # special values + now = SpecialValue(lambda: datetime.datetime.now().time(), "Now") + allballs = SpecialValue(datetime.time(0, 0, 0, 0, zoneinfo.ZoneInfo("UTC")), "Allballs") + + def __init__(self, precision: int): # noqa + self.precision = precision + self.sql = f"TIME({precision})" + + +class Interval(SQLType): + """ + Time interval. + + Uses 16 bytes of storage. + """ + + py = datetime.timedelta + sql = "INTERVAL" + + def __init__(self, precision: int): # noqa + self.precision = precision + + +# endregion + + +# region: Boolean Types + +class Boolean(SQLType): + """ + True or False value. + + Uses 1 byte of storage. + """ + + py = bool + sql = "BOOLEAN" + + +# endregion + + +# region: Collection Types + +class JSON(SQLType): + """JSON data objects.""" + + py = dict + sql = "JSON" + + +class JSONB(SQLType): + """JSONB data objects.""" + + py = dict + sql = "JSONB" + + +class Array(SQLType): + """Variable length array containing any supported type.""" + + py = list + + def __init__(self, element_type: SQLType, size: int = ''): # noqa + self.element_type = element_type + self.element_size = size + self.sql = f"{element_type}[{size}]" + +# endregion diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2cd24f2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,240 @@ +[[package]] +name = "asyncpg" +version = "0.22.0" +description = "An asyncio PostgreSQL driver" +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +dev = ["Cython (>=0.29.20,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)", "pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"] +docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"] +test = ["pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"] + +[[package]] +name = "attrs" +version = "20.3.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] + +[[package]] +name = "flake8" +version = "3.9.0" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] +name = "flake8-annotations" +version = "2.6.1" +description = "Flake8 Type Annotation Checks" +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0.0" + +[package.dependencies] +flake8 = ">=3.7,<4.0" + +[[package]] +name = "flake8-bugbear" +version = "21.3.2" +description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +attrs = ">=19.2.0" +flake8 = ">=3.0.0" + +[package.extras] +dev = ["coverage", "black", "hypothesis", "hypothesmith"] + +[[package]] +name = "flake8-docstrings" +version = "1.6.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-string-format" +version = "0.3.0" +description = "string format checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "flake8-tidy-imports" +version = "4.2.1" +description = "A flake8 plugin that helps you write tidier imports." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" + +[[package]] +name = "flake8-todo" +version = "0.7" +description = "TODO notes checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pycodestyle = ">=2.0.0,<3.0.0" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydocstyle" +version = "6.0.0" +description = "Python docstring style checker" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +snowballstemmer = "*" + +[[package]] +name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "snowballstemmer" +version = "2.1.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "sqlparse" +version = "0.4.1" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[metadata] +lock-version = "1.1" +python-versions = "^3.9" +content-hash = "8577a8dc71b2e4fe3a0dfc639c679f4517682dcf4e05d6d7c44ee63eef9cbdb5" + +[metadata.files] +asyncpg = [ + {file = "asyncpg-0.22.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:ccd75cfb4710c7e8debc19516e2e1d4c9863cce3f7a45a3822980d04b16f4fdd"}, + {file = "asyncpg-0.22.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3af9a8511569983481b5cf94db17b7cbecd06b5398aac9c82e4acb69bb1f4090"}, + {file = "asyncpg-0.22.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d1cb6e5b58a4e017335f2a1886e153a32bd213ffa9f7129ee5aced2a7210fa3c"}, + {file = "asyncpg-0.22.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0f4604a88386d68c46bf7b50c201a9718515b0d2df6d5e9ce024d78ed0f7189c"}, + {file = "asyncpg-0.22.0-cp36-cp36m-win_amd64.whl", hash = "sha256:b37efafbbec505287bd1499a88f4b59ff2b470709a1d8f7e4db198d3e2c5a2c4"}, + {file = "asyncpg-0.22.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:1d3efdec14f3fbcc665b77619f8b420564f98b89632a21694be2101dafa6bcf2"}, + {file = "asyncpg-0.22.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f1df7cfd12ef484210717e7827cc2d4d550b16a1b4dd4566c93914c7a2259352"}, + {file = "asyncpg-0.22.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f514b13bc54bde65db6cd1d0832ae27f21093e3cb66f741e078fab77768971c"}, + {file = "asyncpg-0.22.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:82e23ba5b37c0c7ee96f290a95cbf9815b2d29b302e8b9c4af1de9b7759fd27b"}, + {file = "asyncpg-0.22.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:062e4ff80e68fe56066c44a8c51989a98785904bf86f49058a242a5887be6ce3"}, + {file = "asyncpg-0.22.0-cp38-cp38-win_amd64.whl", hash = "sha256:e7a67fb0244e4a5b3baaa40092d0efd642da032b5e891d75947dab993b47d925"}, + {file = "asyncpg-0.22.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:1bbe5e829de506c743cbd5240b3722e487c53669a5f1e159abcc3b92a64a985e"}, + {file = "asyncpg-0.22.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2cb730241dfe650b9626eae00490cca4cfeb00871ed8b8f389f3a4507b328683"}, + {file = "asyncpg-0.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e3875c82ae609b21e562e6befdc35e52c4290e49d03e7529275d59a0595ca97"}, + {file = "asyncpg-0.22.0.tar.gz", hash = "sha256:348ad471d9bdd77f0609a00c860142f47c81c9123f4064d13d65c8569415d802"}, +] +attrs = [ + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, +] +flake8 = [ + {file = "flake8-3.9.0-py2.py3-none-any.whl", hash = "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff"}, + {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"}, +] +flake8-annotations = [ + {file = "flake8-annotations-2.6.1.tar.gz", hash = "sha256:40a4d504cdf64126ea0bdca39edab1608bc6d515e96569b7e7c3c59c84f66c36"}, + {file = "flake8_annotations-2.6.1-py3-none-any.whl", hash = "sha256:eabbfb2dd59ae0e9835f509f930e79cd99fa4ff1026fe6ca073503a57407037c"}, +] +flake8-bugbear = [ + {file = "flake8-bugbear-21.3.2.tar.gz", hash = "sha256:cadce434ceef96463b45a7c3000f23527c04ea4b531d16c7ac8886051f516ca0"}, + {file = "flake8_bugbear-21.3.2-py36.py37.py38-none-any.whl", hash = "sha256:5d6ccb0c0676c738a6e066b4d50589c408dcc1c5bf1d73b464b18b73cd6c05c2"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, + {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, +] +flake8-string-format = [ + {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, + {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, +] +flake8-tidy-imports = [ + {file = "flake8-tidy-imports-4.2.1.tar.gz", hash = "sha256:52e5f2f987d3d5597538d5941153409ebcab571635835b78f522c7bf03ca23bc"}, + {file = "flake8_tidy_imports-4.2.1-py3-none-any.whl", hash = "sha256:76e36fbbfdc8e3c5017f9a216c2855a298be85bc0631e66777f4e6a07a859dc4"}, +] +flake8-todo = [ + {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +pycodestyle = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] +pydocstyle = [ + {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"}, + {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"}, +] +pyflakes = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, + {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, +] +sqlparse = [ + {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, + {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a6c60f1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[tool.poetry] +name = "everstone" +version = "0.1.0" +description = "Simple Database Query Generator" +authors = ["scragly <29337040+scragly@users.noreply.github.com>"] +license = "MIT" + +[tool.poetry.dependencies] +python = "^3.9" +asyncpg = "^0.22.0" +sqlparse = "^0.4.1" + +[tool.poetry.dev-dependencies] +flake8 = "^3.9.0" +flake8-annotations = "^2.6.1" +flake8-bugbear = "^21.3.2" +flake8-docstrings = "^1.6.0" +flake8-string-format = "^0.3.0" +flake8-tidy-imports = "^4.2.1" +flake8-todo = "^0.7" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"