diff --git a/pyignite/__init__.py b/pyignite/__init__.py index 1b0a9c2..3508976 100644 --- a/pyignite/__init__.py +++ b/pyignite/__init__.py @@ -16,5 +16,8 @@ from pyignite.client import Client from pyignite.aio_client import AioClient from pyignite.binary import GenericObjectMeta +from .dbapi import connect __version__ = '0.6.0-dev' + +__all__ = [ 'Client', 'connect' ] diff --git a/pyignite/dbapi/__init__.py b/pyignite/dbapi/__init__.py new file mode 100644 index 0000000..aaee234 --- /dev/null +++ b/pyignite/dbapi/__init__.py @@ -0,0 +1,86 @@ +# 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 .dbclient import DBClient +from .errors import * +from .. import constants +from urllib.parse import urlparse, parse_qs + +apiLevel = '2.0' +threadsafety = 2 +paramstyle = 'qmark' + +def connect(dsn=None, + user=None, password=None, + host=constants.IGNITE_DEFAULT_HOST, port=constants.IGNITE_DEFAULT_PORT, + **kwargs): + """ + Create a new database connection. + + The connection can be specified via DSN: + + ``conn = connect("ignite://localhost/test?param1=value1&...")`` + + or using database and credentials arguments: + + ``conn = connect(database="test", user="default", password="default", + host="localhost", **kwargs)`` + + The basic connection parameters are: + + - *host*: host with running Ignite server. + - *port*: port Ignite server is bound to. + - *database*: database connect to. + - *user*: database user. + - *password*: user's password. + + See defaults in :data:`~pyignite.connection.Connection` + constructor. + + DSN or host is required. + + Any other keyword parameter will be passed to the underlying Connection + class. + + :return: a new connection. + """ + + if dsn is not None: + parsed_dsn = _parse_dsn(dsn) + host = parsed_dsn['host'] + port = parsed_dsn['port'] + + client = DBClient() + client.connect(host, port) + + return client + +def _parse_dsn(dsn): + url_components = urlparse(dsn) + host = url_components.hostname + port = url_components.port or 10800 + if url_components.path is not None: + schema = url_components.path.replace('/', '') + else: + schema = 'PUBLIC' + schema = url_components.path + return { 'host':host, 'port':port, 'schema':schema } + +__all__ = [ + 'connect', 'apiLevel', 'threadsafety', 'paramstyle', + 'Warning', 'Error', 'DataError', 'DatabaseError', 'ProgrammingError', + 'IntegrityError', 'InterfaceError', 'InternalError', 'NotSupportedError', + 'OperationalError', 'IgniteDialect' +] diff --git a/pyignite/dbapi/dbclient.py b/pyignite/dbapi/dbclient.py new file mode 100644 index 0000000..fcb85a8 --- /dev/null +++ b/pyignite/dbapi/dbclient.py @@ -0,0 +1,46 @@ +# 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 ..client import Client +from .dbcursor import DBCursor + +class DBClient (Client): + + + def close(self): + """ + """ + # TODO: close open cursors + super.close() + + def commit(self): + """ + Ignite doesn't have SQL transactions + """ + pass + + def rollback(self): + """ + Ignite doesn't have SQL transactions + """ + pass + + def cursor(self): + """ + Cursors work slightly differently in Ignite versus DBAPI, so + we map from one to the other + """ + return DBCursor(self) + \ No newline at end of file diff --git a/pyignite/dbapi/dbcursor.py b/pyignite/dbapi/dbcursor.py new file mode 100644 index 0000000..68c2b61 --- /dev/null +++ b/pyignite/dbapi/dbcursor.py @@ -0,0 +1,139 @@ +# 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 ..cursors import SqlCursor + +class DBCursor(object): + + def __init__(self, connection): + self.connection = connection + self.cursor = None + self.rowcount = -1 + + @property + def description(self): +# columns = self._columns +# types = [ bool ] + + return [ + [name, None, None, None, None, None, True] + for name in self._columns +# for name, type_code in zip(columns, types) + ] + + def close(self): + """ + Close the cursor now. The cursor will be unusable from this point + forward; an :data:`~pyignite.dbapi.Error` (or subclass) + exception will be raised if any operation is attempted with the + cursor. + """ +# self.connection.disconnect() +# self._state = self._states.CURSOR_CLOSED + +# try: +# # cursor can be already closed +# self.connection.cursors.remove(self) +# except ValueError: +# pass + + def execute(self, operation, parameters=None): + """ + Prepare and execute a database operation (query or command). + + :param operation: query or command to execute. + :param parameters: sequence or mapping that will be bound to + variables in the operation. + :return: None + """ + self.cursor = self.connection.sql(operation, query_args=parameters, include_field_names=True) + self._columns = next(self.cursor) + + def executemany(self, operation, seq_of_parameters): + """ + Prepare a database operation (query or command) and then execute it + against all parameter sequences found in the sequence + `seq_of_parameters`. + + :param operation: query or command to execute. + :param seq_of_parameters: sequences or mappings for execution. + :return: None + """ + pass + + def fetchone(self): + """ + Fetch the next row of a query result set, returning a single sequence, + or None when no more data is available. + + :return: the next row of a query result set or None. + """ + if self.cursor is not None: + return next(self.cursor) + else: + return None + + def fetchmany(self, size=None): + """ + Fetch the next set of rows of a query result, returning a sequence of + sequences (e.g. a list of tuples). An empty sequence is returned when + no more rows are available. + + :param size: amount of rows to return. + :return: list of fetched rows or empty list. + """ + self._check_query_started() + + if size is None: + size = self.arraysize + + if self._stream_results: + if size == -1: + return list(self._rows) + else: + return list(islice(self._rows, size)) + + if size < 0: + rv = self._rows + self._rows = [] + else: + rv = self._rows[:size] + self._rows = self._rows[size:] + + return rv + + def fetchall(self): + """ + Fetch all (remaining) rows of a query result, returning them as a + sequence of sequences (e.g. a list of tuples). + + :return: list of fetched rows. + """ + if self.cursor != None: + rows = [] + for row in self.cursor: + rows.append(row) + else: + return None # error? + + return rows + + def setinputsizes(self, sizes): + # Do nothing. + pass + + def setoutputsize(self, size, column=None): + # Do nothing. + pass diff --git a/pyignite/dbapi/errors.py b/pyignite/dbapi/errors.py new file mode 100644 index 0000000..7bb5c06 --- /dev/null +++ b/pyignite/dbapi/errors.py @@ -0,0 +1,44 @@ +# 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. + +class Warning(Exception): + pass + +class Error(Exception): + pass + +class InterfaceError(Error): + pass + +class DatabaseError(Error): + pass + +class InternalError(DatabaseError): + pass + +class OperationalError(DatabaseError): + pass + +class ProgrammingError(DatabaseError): + pass + +class IntegrityError(DatabaseError): + pass + +class DataError(DatabaseError): + pass + +class NotSupportedError(DatabaseError): + pass diff --git a/pyignite/dbapi/sqlalchemy.py b/pyignite/dbapi/sqlalchemy.py new file mode 100644 index 0000000..a37d617 --- /dev/null +++ b/pyignite/dbapi/sqlalchemy.py @@ -0,0 +1,280 @@ +# 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 sqlalchemy import types +from sqlalchemy.engine import default +from sqlalchemy.sql import compiler + +from .. import dbapi +# from . dbclient import DBClient +from .. datatypes.type_names import * + +RESERVED_SCHEMAS = ['IGNITE', 'SYS'] + + +# type_map = { +# "char": types.String, +# "varchar": types.String, +# "float": types.Float, +# "decimal": types.Float, +# "real": types.Float, +# "double": types.Float, +# "boolean": types.Boolean, +# "tinyint": types.BigInteger, +# "smallint": types.BigInteger, +# "integer": types.BigInteger, +# "bigint": types.BigInteger, +# "timestamp": types.TIMESTAMP, +# "date": types.DATE, +# "other": types.BLOB, +# } + +type_map = { + NAME_BYTE: types.BigInteger, + NAME_SHORT: types.BigInteger, + NAME_INT: types.BigInteger, + NAME_LONG: types.BigInteger, + NAME_FLOAT: types.Float, + NAME_DOUBLE: types.Float, + NAME_CHAR: types.String, + NAME_BOOLEAN: types.Boolean, + NAME_STRING: types.String, + # NAME_UUID = 'java.util.UUID' + # NAME_DATE = 'java.util.Date' + # NAME_BYTE_ARR = 'class [B' + # NAME_SHORT_ARR = 'class [S' + # NAME_INT_ARR = 'class [I' + # NAME_LONG_ARR = 'class [J' + # NAME_FLOAT_ARR = 'class [F' + # NAME_DOUBLE_ARR = 'class [D' + # NAME_CHAR_ARR = 'class [C' + # NAME_BOOLEAN_ARR = 'class [Z' + # NAME_STRING_ARR = 'class [Ljava.lang.String;' + # NAME_UUID_ARR = 'class [Ljava.util.UUID;' + # NAME_DATE_ARR = 'class [Ljava.util.Date;' + # NAME_OBJ_ARR = 'class [Ljava.lang.Object;' + # NAME_COL = 'java.util.Collection' + # NAME_MAP = 'java.util.Map' + # NAME_DECIMAL = 'java.math.BigDecimal' + # NAME_DECIMAL_ARR = 'class [Ljava.math.BigDecimal;' + # NAME_TIMESTAMP = 'java.sql.Timestamp' + # NAME_TIMESTAMP_ARR = 'class [Ljava.sql.Timestamp;' + # NAME_TIME = 'java.sql.Time' + # NAME_TIME_ARR = 'class [Ljava.sql.Time;' +} + + +class UniversalSet(object): + def __contains__(self, item): + return True + + +class IgniteIdentifierPreparer(compiler.IdentifierPreparer): + reserved_words = UniversalSet() + + +class IgniteCompiler(compiler.SQLCompiler): + pass + +class IgniteDDLCompiler(compiler.DDLCompiler): + def visit_foreign_key_constraint(self, constraint, **kw): + return None + +class IgniteTypeCompiler(compiler.GenericTypeCompiler): + def visit_REAL(self, type_, **kwargs): + return "DOUBLE" + + def visit_NUMERIC(self, type_, **kwargs): + return "LONG" + + visit_DECIMAL = visit_NUMERIC + visit_INTEGER = visit_NUMERIC + visit_SMALLINT = visit_NUMERIC + visit_BIGINT = visit_NUMERIC + visit_BOOLEAN = visit_NUMERIC + visit_TIMESTAMP = visit_NUMERIC + visit_DATE = visit_NUMERIC + + def visit_CHAR(self, type_, **kwargs): + return "VARCHAR" + + visit_NCHAR = visit_CHAR + visit_VARCHAR = visit_CHAR + visit_NVARCHAR = visit_CHAR + visit_TEXT = visit_CHAR + + def visit_DATETIME(self, type_, **kwargs): + return "LONG" + + def visit_TIME(self, type_, **kwargs): + return "LONG" + + def visit_BLOB(self, type_, **kwargs): + return "COMPLEX" + + visit_CLOB = visit_BLOB + visit_NCLOB = visit_BLOB + visit_VARBINARY = visit_BLOB + visit_BINARY = visit_BLOB + + +class IgniteDialect(default.DefaultDialect): + + name = "ignite" + scheme = "thin" +# driver = "rest" + user = None + password = None + preparer = IgniteIdentifierPreparer + statement_compiler = IgniteCompiler + type_compiler = IgniteTypeCompiler + ddl_compiler = IgniteDDLCompiler + supports_alter = False + supports_views = False + postfetch_lastrowid = False + supports_pk_autoincrement = False + supports_default_values = False + supports_empty_insert = False + supports_unicode_statements = True + supports_unicode_binds = True + returns_unicode_strings = True + description_encoding = None + supports_native_boolean = True + + def __init__(self, context=None, *args, **kwargs): + super(IgniteDialect, self).__init__(*args, **kwargs) + self.context = context or {} + + @classmethod + def dbapi(cls): + return dbapi + + def create_connect_args(self, url): + kwargs = { + "host": url.host, + "port": url.port or 10800, + "user": url.username or None, + "password": url.password or None, + "path": url.database, + "scheme": self.scheme, + "context": self.context, + "header": url.query.get("header") == "true", + } + return ([], kwargs) + + def get_schema_names(self, connection, **kwargs): + # Each Ignite datasource appears as a table in the "SYS" schema. + result = connection.execute( + "SELECT SCHEMA_NAME FROM SYS.SCHEMAS" + ) + + return [ + row.SCHEMA_NAME for row in result if row.SCHEMA_NAME not in RESERVED_SCHEMAS + ] + + def has_table(self, connection, table_name, schema=None): + query = """ + SELECT COUNT(*) > 0 AS exists_ + FROM SYS.TABLES + WHERE TABLE_NAME = '{table_name}' + """.format( + table_name=table_name + ) + + result = connection.execute(query) + return result.fetchone().EXISTS_ + + def get_table_names(self, connection, schema=None, **kwargs): + query = "SELECT TABLE_NAME FROM SYS.TABLES" + if schema: + query = "{query} WHERE TABLE_SCHEMA = '{schema}'".format( + query=query, schema=schema + ) + + result = connection.execute(query) + return [row.TABLE_NAME for row in result] + + def get_view_names(self, connection, schema=None, **kwargs): + return [] + + def get_table_options(self, connection, table_name, schema=None, **kwargs): + return {} + + def get_columns(self, connection, table_name, schema=None, **kwargs): + query = """ + SELECT COLUMN_NAME, + TYPE, + NULLABLE, + DEFAULT_VALUE + FROM SYS.TABLE_COLUMNS + WHERE TABLE_NAME = '{table_name}' + """.format( + table_name=table_name + ) + if schema: + query = "{query} AND SCHEMA_NAME = '{schema}'".format( + query=query, schema=schema + ) + + result = connection.execute(query) + + return [ + { + "name": row.COLUMN_NAME, + "type": type_map[row.DATA_TYPE.lower()], + "nullable": get_is_nullable(row.IS_NULLABLE), + "default": get_default(row.COLUMN_DEFAULT), + } + for row in result + ] + + def get_pk_constraint(self, connection, table_name, schema=None, **kwargs): + return {"constrained_columns": [], "name": None} + + def get_foreign_keys(self, connection, table_name, schema=None, **kwargs): + return [] + + def get_check_constraints(self, connection, table_name, schema=None, **kwargs): + return [] + + def get_table_comment(self, connection, table_name, schema=None, **kwargs): + return {"text": ""} + + def get_indexes(self, connection, table_name, schema=None, **kwargs): + return [] + + def get_unique_constraints(self, connection, table_name, schema=None, **kwargs): + return [] + + def get_view_definition(self, connection, view_name, schema=None, **kwargs): + pass + + def do_rollback(self, dbapi_connection): + pass + + def _check_unicode_returns(self, connection, additional_tests=None): + return True + + def _check_unicode_description(self, connection): + return True + +def get_is_nullable(Ignite_is_nullable): + # this should be 'YES' or 'NO'; we default to no + return Ignite_is_nullable.lower() == "yes" + + +def get_default(Ignite_column_default): + # currently unused, returns '' + return str(Ignite_column_default) if Ignite_column_default != "" else None \ No newline at end of file