From 2e14aeaace3debc722f380d31eaf07e1f823b211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ege=20Emir=20=C3=96zkan?= Date: Fri, 14 Aug 2020 11:45:39 +0300 Subject: [PATCH] Add basic migrations --- datalite/__init__.py | 2 +- datalite/commons.py | 54 ++++++- datalite/datalite_decorator.py | 43 +----- datalite/fetch.py | 20 +-- datalite/migrations.py | 160 +++++++++++++++++++++ docs/_build/doctrees/datalite.doctree | Bin 70231 -> 77321 bytes docs/_build/doctrees/environment.pickle | Bin 15617 -> 15317 bytes docs/_build/html/_sources/datalite.rst.txt | 6 + docs/_build/html/datalite.html | 55 ++++--- docs/_build/html/genindex.html | 24 +++- docs/_build/html/index.html | 1 + docs/_build/html/objects.inv | 4 +- docs/_build/html/py-modindex.html | 5 + docs/_build/html/searchindex.js | 2 +- docs/conf.py | 2 +- docs/datalite.rst | 6 + docs/modules.rst | 7 - setup.py | 2 +- test/main_tests.py | 33 +++++ 19 files changed, 339 insertions(+), 87 deletions(-) create mode 100644 datalite/migrations.py delete mode 100644 docs/modules.rst diff --git a/datalite/__init__.py b/datalite/__init__.py index c6ab2f5..a45fd71 100644 --- a/datalite/__init__.py +++ b/datalite/__init__.py @@ -1,3 +1,3 @@ -__all__ = ['commons', 'datalite_decorator', 'fetch', 'datalite'] +__all__ = ['commons', 'datalite_decorator', 'fetch', 'migrations', 'datalite'] from .datalite_decorator import datalite diff --git a/datalite/commons.py b/datalite/commons.py index bc04e43..2876db5 100644 --- a/datalite/commons.py +++ b/datalite/commons.py @@ -1,4 +1,6 @@ -from typing import Any, Optional, Dict +from dataclasses import Field +from typing import Any, Optional, Dict, List +import sqlite3 as sql def _convert_type(type_: Optional[type], type_overload: Dict[Optional[type], str]) -> str: @@ -33,4 +35,52 @@ def _convert_sql_format(value: Any) -> str: elif isinstance(value, bytes): return '"' + str(value).replace("b'", "")[:-1] + '"' else: - return str(value) \ No newline at end of file + return str(value) + + +def _get_table_cols(cur: sql.Cursor, table_name: str) -> List[str]: + """ + Get the column data of a table. + + :param cur: Cursor in database. + :param table_name: Name of the table. + :return: the information about columns. + """ + cur.execute(f"PRAGMA table_info({table_name});") + return [row_info[1] for row_info in cur.fetchall()][1:] + + +def _get_default(default_object: object, type_overload: Dict[Optional[type], str]) -> str: + """ + Check if the field's default object is filled, + if filled return the string to be put in the, + database. + :param default_object: The default field of the field. + :param type_overload: Type overload table. + :return: The string to be put on the table statement, + empty string if no string is necessary. + """ + if type(default_object) in type_overload: + return f' DEFAULT {_convert_sql_format(default_object)}' + return "" + + +def _create_table(class_: type, cursor: sql.Cursor, type_overload: Dict[Optional[type], str]) -> None: + """ + Create the table for a specific dataclass given + :param class_: A dataclass. + :param cursor: Current cursor instance. + :param type_overload: Overload the Python -> SQLDatatype table + with a custom table, this is that custom table. + :return: None. + """ + fields: List[Field] = [class_.__dataclass_fields__[key] for + key in class_.__dataclass_fields__.keys()] + fields.sort(key=lambda field: field.name) # Since dictionaries *may* be unsorted. + sql_fields = ', '.join(f"{field.name} {_convert_type(field.type, type_overload)}" + f"{_get_default(field.default, type_overload)}" for field in fields) + sql_fields = "obj_id INTEGER PRIMARY KEY AUTOINCREMENT, " + sql_fields + cursor.execute(f"CREATE TABLE IF NOT EXISTS {class_.__name__.lower()} ({sql_fields});") + +type_table: Dict[Optional[type], str] = {None: "NULL", int: "INTEGER", float: "REAL", + str: "TEXT", bytes: "BLOB"} diff --git a/datalite/datalite_decorator.py b/datalite/datalite_decorator.py index b3d5bad..b9c69c1 100644 --- a/datalite/datalite_decorator.py +++ b/datalite/datalite_decorator.py @@ -6,40 +6,7 @@ a class bound to a sqlite3 database. from typing import Dict, Optional, List, Callable from dataclasses import Field, asdict import sqlite3 as sql -from .commons import _convert_sql_format, _convert_type - - -def _get_default(default_object: object, type_overload: Dict[Optional[type], str]) -> str: - """ - Check if the field's default object is filled, - if filled return the string to be put in the, - database. - :param default_object: The default field of the field. - :param type_overload: Type overload table. - :return: The string to be put on the table statement, - empty string if no string is necessary. - """ - if type(default_object) in type_overload: - return f' DEFAULT {_convert_sql_format(default_object)}' - return "" - - -def _create_table(class_: type, cursor: sql.Cursor, type_overload: Dict[Optional[type], str]) -> None: - """ - Create the table for a specific dataclass given - :param class_: A dataclass. - :param cursor: Current cursor instance. - :param type_overload: Overload the Python -> SQLDatatype table - with a custom table, this is that custom table. - :return: None. - """ - fields: List[Field] = [class_.__dataclass_fields__[key] for - key in class_.__dataclass_fields__.keys()] - fields.sort(key=lambda field: field.name) # Since dictionaries *may* be unsorted. - sql_fields = ', '.join(f"{field.name} {_convert_type(field.type, type_overload)}" - f"{_get_default(field.default, type_overload)}" for field in fields) - sql_fields = "obj_id INTEGER PRIMARY KEY AUTOINCREMENT, " + sql_fields - cursor.execute(f"CREATE TABLE IF NOT EXISTS {class_.__name__.lower()} ({sql_fields});") +from .commons import _convert_sql_format, _convert_type, _create_table, type_table def _create_entry(self) -> None: @@ -105,14 +72,14 @@ def datalite(db_path: str, type_overload: Optional[Dict[Optional[type], str]] = :return: The new dataclass. """ def decorator(dataclass_: type, *args_i, **kwargs_i): - type_table: Dict[Optional[type], str] = {None: "NULL", int: "INTEGER", float: "REAL", - str: "TEXT", bytes: "BLOB"} + types_table = type_table.copy() if type_overload is not None: - type_table.update(type_overload) + types_table.update(type_overload) with sql.connect(db_path) as con: cur: sql.Cursor = con.cursor() - _create_table(dataclass_, cur, type_table) + _create_table(dataclass_, cur, types_table) setattr(dataclass_, 'db_path', db_path) # We add the path of the database to class itself. + setattr(dataclass_, 'types_table', types_table) # We add the type table for migration. dataclass_.create_entry = _create_entry dataclass_.remove_entry = _remove_entry dataclass_.update_entry = _update_entry diff --git a/datalite/fetch.py b/datalite/fetch.py index 1b9d164..c39f100 100644 --- a/datalite/fetch.py +++ b/datalite/fetch.py @@ -1,6 +1,6 @@ import sqlite3 as sql from typing import List, Tuple, Any -from .commons import _convert_sql_format +from .commons import _convert_sql_format, _get_table_cols def _insert_pagination(query: str, page: int, element_count: int) -> str: @@ -35,18 +35,6 @@ def is_fetchable(class_: type, obj_id: int) -> bool: return bool(cur.fetchall()) -def _get_table_cols(cur: sql.Cursor, table_name: str) -> List[str]: - """ - Get the column data of a table. - - :param cur: Cursor in database. - :param table_name: Name of the table. - :return: the information about columns. - """ - cur.execute(f"PRAGMA table_info({table_name});") - return [row_info[1] for row_info in cur.fetchall()][1:] - - def fetch_equals(class_: type, field: str, value: Any, ) -> Any: """ Fetch a class_ type variable from its bound db. @@ -112,7 +100,7 @@ def fetch_if(class_: type, condition: str, page: int = 0, element_count: int = 1 :param page: Which page to retrieve, default all. (0 means closed). :param element_count: Element count in each page. :return: A tuple of records that fit the given condition - of given type class_. + of given type class_. """ table_name = class_.__name__.lower() with sql.connect(getattr(class_, 'db_path')) as con: @@ -146,7 +134,7 @@ def fetch_range(class_: type, range_: range) -> tuple: :param class_: Class of the records. :param range_: Range of the object ids. :return: A tuple of class_ type objects whose values - come from the class_' bound database. + come from the class_' bound database. """ return tuple(fetch_from(class_, obj_id) for obj_id in range_ if is_fetchable(class_, obj_id)) @@ -159,7 +147,7 @@ def fetch_all(class_: type, page: int = 0, element_count: int = 10) -> tuple: :param page: Which page to retrieve, default all. (0 means closed). :param element_count: Element count in each page. :return: All the records of type class_ in - the bound database as a tuple. + the bound database as a tuple. """ try: db_path = getattr(class_, 'db_path') diff --git a/datalite/migrations.py b/datalite/migrations.py new file mode 100644 index 0000000..c766985 --- /dev/null +++ b/datalite/migrations.py @@ -0,0 +1,160 @@ +""" +Migrations module deals with migrating data when the object +definitions change. This functions deal with Schema Migrations. +""" +from dataclasses import Field +from os.path import exists +from typing import Dict, Tuple, List +import sqlite3 as sql + +from .commons import _create_table, _get_table_cols + + +def _get_db_table(class_: type) -> Tuple[str, str]: + """ + Check if the class is a datalite class, the database exists + and the table exists. Return database and table names. + + :param class_: A datalite class. + :return: A tuple of database and table names. + """ + database_name: str = getattr(class_, 'db_path', None) + if not database_name: + raise TypeError(f"{class_.__name__} is not a datalite class.") + table_name: str = class_.__name__.lower() + if not exists(database_name): + raise FileNotFoundError(f"{database_name} does not exist") + with sql.connect(database_name) as con: + cur: sql.Cursor = con.cursor() + cur.execute(f"SELECT count(*) FROM sqlite_master " + f"WHERE type='table' AND name='{table_name}';") + count: int = int(cur.fetchone()[0]) + if not count: + raise FileExistsError(f"Table, {table_name}, already exists.") + return database_name, table_name + + +def _get_table_column_names(database_name: str, table_name: str) -> Tuple[str]: + """ + Get the column names of table. + + :param database_name: Name of the database the table + resides in. + :param table_name: Name of the table. + :return: A tuple holding the column names of the table. + """ + with sql.connect(database_name) as con: + cur: sql.Cursor = con.cursor() + cols: List[str] = _get_table_cols(cur, table_name) + return tuple(cols) + + +def _copy_records(database_name: str, table_name: str): + """ + Copy all records from a table. + + :param database_name: Name of the database. + :param table_name: Name of the table. + :return: A generator holding dataclass asdict representations. + """ + with sql.connect(database_name) as con: + cur: sql.Cursor = con.cursor() + cur.execute(f'SELECT * FROM {table_name};') + values = cur.fetchall() + keys = _get_table_cols(cur, table_name) + keys.insert(0, 'obj_id') + records = (dict(zip(keys, value)) for value in values) + return records + + +def _drop_table(database_name: str, table_name: str) -> None: + """ + Drop a table. + + :param database_name: Name of the database. + :param table_name: Name of the table to be dropped. + :return: None. + """ + with sql.connect(database_name) as con: + cur: sql.Cursor = con.cursor() + cur.execute(f'DROP TABLE {table_name};') + con.commit() + + +def _modify_records(data, col_to_del: Tuple[str], col_to_add: Tuple[str], + flow: Dict[str, str]) -> Tuple[Dict[str, str]]: + """ + Modify the asdict records in accordance + with schema migration rules provided. + + :param data: Data kept as asdict in tuple. + :param col_to_del: Column names to delete. + :param col_to_add: Column names to add. + :param flow: A dictionary that explain + if the data from a deleted column + will be transferred to a column + to be added. + :return: The modified data records. + """ + records = [] + for record in data: + record_mod = {} + for key in record.keys(): + if key in col_to_del and key in flow: + record_mod[flow[key]] = record[key] + else: + record_mod[key] = record[key] + for key_to_add in col_to_add: + if key_to_add not in record_mod: + record_mod[key_to_add] = None + records.append(record_mod) + return records + + +def _migrate_records(class_: type, database_name: str, data, + col_to_del: Tuple[str], col_to_add: Tuple[str], flow: Dict[str, str]) -> None: + """ + Migrate the records into the modified table. + + :param class_: Class of the entries. + :param database_name: Name of the database. + :param data: Data, asdict tuple. + :param col_to_del: Columns to be deleted. + :param col_to_add: Columns to be added. + :param flow: Flow dictionary stating where + column data will be transferred. + :return: None. + """ + with sql.connect(database_name) as con: + cur: sql.Cursor = con.cursor() + _create_table(class_, cur, getattr(class_, 'types_table')) + con.commit() + new_records = _modify_records(data, col_to_del, col_to_add, flow) + for record in new_records: + del record['obj_id'] + class_(**record).create_entry() + + +def basic_migrate(class_: type, column_transfer: dict = None) -> None: + """ + Given a class, compare its previous table, + delete the fields that no longer exist, + create new columns for new fields. If the + column_flow parameter is given, migrate elements + from previous column to the new ones. + + :param class_: Datalite class to migrate. + :param column_transfer: A dictionary showing which + columns will be copied to new ones. + :return: None. + """ + database_name, table_name = _get_db_table(class_) + table_column_names: Tuple[str] = _get_table_column_names(database_name, table_name) + values = class_.__dataclass_fields__.values() + data_fields: Tuple[Field] = tuple(field for field in values) + data_field_names: Tuple[str] = tuple(field.name for field in data_fields) + columns_to_be_deleted: Tuple[str] = tuple(column for column in table_column_names if column not in data_field_names) + columns_to_be_added: Tuple[str] = tuple(column for column in data_field_names if column not in table_column_names) + records = _copy_records(database_name, table_name) + _drop_table(database_name, table_name) + _migrate_records(class_, database_name, records, columns_to_be_deleted, columns_to_be_added, column_transfer) diff --git a/docs/_build/doctrees/datalite.doctree b/docs/_build/doctrees/datalite.doctree index e83131f57311cd1541765b6adea3869a33ba6e97..c5e0eea6fb4bb6a403d8e8303827bae3b6159405 100644 GIT binary patch literal 77321 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 diff --git a/docs/_build/doctrees/environment.pickle b/docs/_build/doctrees/environment.pickle index e02ee48a4efb31c4c0f94a8d993ed28cade2725c..6ed89e5601c6c497ca51172e2360164fa657e949 100644 GIT binary patch delta 4538 zcmcIoTWl0n7;d-QZDB#6UVv_Bx0K7S!fs2iv{g!>F|<-HC7RIGb#`{zo#S@4*_jEg z2G<%&txP25j6QgY21F#h&}s}JgkXFiVyY2C03$}gC`c52@IfDl|2b!OXZBL)H1Xv> zGv}Q7{{R2ZcWyKC!o@v>FBiUfzImds*|n%R#3@`vR0Lfs6s3?b^huBFmaU9E>rZaH zG!-oADr!MJw!A#@hEL>ed>s#!H9Hes4(g}}pDlYVP~lhQm_H&0{e#0wI4afn8vO*E zk8rXq$iBg0UCY&{Uyy{D$PaQ#nD$xf^U?1}Q2qh2KgKCyRFd&8WnRat5CuAbA1+_X z9>;NO`Lg^`{A_va{6o;D*>?#;ijH->u%bHuC~m8G)TucJxtJg+Iy!M;JiV)3?7@_2+8>Vx*v>`IiEsUzAKWpV9dZ# zJmIUdH5A|*zLE0BNf=B$DPNjHj;b-xv9|7w9o{%yk$f?jC7L}VN`l_GfTgf%mMmy# zQ_oC?fU*;clj)E#4`@aSN{dpI-bZ98Lt}=Lrk5Bv619>oQb^@tAJ1$?j%*=ptvv_Hu+exegM%+=n&>N5OtIn{R(wlF1D94WiYJ#>^eeP5 zpfM{z6fYsx>nVtRjFRct@TAtt%~{*yEOd-du3olaLN>EdR zJ3S9EsT$OLWCbWUHHWQ0h-Qo*QuKbn=rw7IWS^4MtQ6Jdt%T!Dm)BLpR4`>F&J<_A zdrLDpJ8uQZoUb9Lb5umKs_GTYUpKH#u7ZrMsp%L`r+ci}y>g_;Q5IUd+cuHLZ$nm!eHA7VwYQY<7YkQuc&l{+11v`#5 zf`xcD(LxPsh2$_!vO`ISiQ5^;msM#+nH&H>5B|FLKt7Lm*L8tCR<{f6-MZ~yx79xd z_TBoOVDC`d*06K82lazX&BZj`?9L@>t@Iv>4|jAa5E4&N{6S6@`2d@l_-_4@{^TT; zWG~zmbAbEIpOD)Jpb4s~*$^ArcEE+PDq<0&5n>;J3npd19ZxkZEh8!zry>|vQ>@wG zl1eCR$8#GuIp{4;z}<~2oNoazJ41;0mBuxu_Xxhaz7qQy9QaOM(L7{yjN-o={icpF z>|eju^seIm^;M?#A$a$e>8`;1NJ3CU2&f(@#I>pkHKM3doTx8W{eRM z{X1Z2?->wl^1qMxi>EtO(Kyr+qTVLRI_cuvN>|l(m}tW=7d#2JpefuD?ig1fPIQX& z{sK&*mBf{5HAbO;9Qf%lKs(hm-rH23-;UpFT4uXo$ETat*uJyl-}y#1MI14?kDWwY&H JJDzOW_8%T^h28)F delta 4568 zcmb_geQZ-z6yLhOZUYuR+#I;Uy0OA08{60ztXp&hmAJ~-#*7%k=4<;#d+EB-z88jp zlo@F=c?9)7iJE9Y3C2a@7J`N-694ccL<~yQ#28}?|A-jS2qx1Q&po&8d#ml>E&Oxt zyC3Iw&-vYRKhFDR&*;;_0pX=vZIeQ4Nr~VOdZiIf%^e)_Dgi01sd-*e76X1+I^A9J zi=`>qv1!A17sW^92bbt0G;Ev7KlL!#Zpq4GHTR;<+%~#xS!+R3@rXuZ^z~({Xl+@g z?Kv1n4$~*f9F_Qa2-WeS)edSsnrf5%GBaeqRz;%pSXsOCIF9L+M%Wm`><6_` z9CecQC(Q@R|j7|9_h-!VfuY)JDaf@V08HTIC^6oD|k`oW53m-&$t2?h5jhAMl6eeptRiAS<26RrFeUT_l}m zNE(oUIFFP;lZ?Z32BMAplXsa_NY_8`g>*s57V$KcK=c`2ypKgbIm@db(2pxhvZHje zy3mEp=Id5)xW8cF?ckDV2&#~Cv~Bs~2wL+ys*_)#NXG{G6FSsfAXg4a86BU5L77~c zKDQoxQTn)}eyJlTD@#=K2E`GtG%WixmsQR6h{LjfD5!PPQ;y9!%-Z=YinE(u=&p{$ zcj4+#Sk_c)z#o)AgY!@bNTzvU&dqfzfq*3Uv%rW)G$%zOseVOZJS-Joqv?);nGDPj zUfir}&^jBlNE`aYgFUf2eGo3wBl8Bpr(3rwaw|g*`8!hwZuK^XPd;S=<6~Y{2yD{KS#>q}nFEmU3c z*J}YA%AUf2#~wttpEC}86%;t>+);fK-v(kQ?5a4%9S6>AZbKSaxi~+0`9W>T8hjr- zV8-KQj9gh+oEjsoW`NSi$a*snT}F&l>h73+o&BrytGd#{D4@Es@zv(a!Nbs%L#OK6 z+96rP2?@S5r`p{Izp!Buisky6nv_- zt$PRELjgSrnV^^J$FlqAj)n@52O5e%9&ME1B)EW$PsSpsADWr&k-A zwny;$0LoF2W8@i-VBWJ>C~j2<0p?((X)pWu7TtnN3fVrI3 zt}Un~`o!nK1T}9!33@Omiox?!A)lbTkBI_-3QmWV2j5j>dY?LvzlZIQ z;{OpgVaf - + @@ -83,12 +83,10 @@

Contents:

Returns
-

All the records of type class_ in

+

All the records of type class_ in +the bound database as a tuple.

-

the bound database as a tuple.

@@ -252,10 +248,10 @@ provided they fit the given condition

Returns
-

A tuple of records that fit the given condition

+

A tuple of records that fit the given condition +of given type class_.

-

of given type class_.

@@ -270,10 +266,10 @@ provided they fit the given condition

Returns
-

A tuple of class_ type objects whose values

+

A tuple of class_ type objects whose values +come from the class_’ bound database.

-

come from the class_’ bound database.

@@ -316,6 +312,33 @@ given value.

+ +
+

datalite.migrations module

+

Migrations module deals with migrating data when the object +definitions change. This functions deal with Schema Migrations.

+
+
+datalite.migrations.basic_migrate(class_: type, column_transfer: dict = None) → None
+

Given a class, compare its previous table, +delete the fields that no longer exist, +create new columns for new fields. If the +column_flow parameter is given, migrate elements +from previous column to the new ones.

+
+
Parameters
+
    +
  • class – Datalite class to migrate.

  • +
  • column_transfer – A dictionary showing which +columns will be copied to new ones.

  • +
+
+
Returns
+

None.

+
+
+
+
@@ -328,7 +351,7 @@ given value.

diff --git a/docs/_build/html/genindex.html b/docs/_build/html/genindex.html index e4d9979..41ccd67 100644 --- a/docs/_build/html/genindex.html +++ b/docs/_build/html/genindex.html @@ -150,24 +150,40 @@

Index

- D + B + | D | F | I | M
+

B

+ + +
+

D

- + @@ -209,6 +225,8 @@ diff --git a/docs/_build/html/index.html b/docs/_build/html/index.html index 078c510..ecc3a79 100644 --- a/docs/_build/html/index.html +++ b/docs/_build/html/index.html @@ -162,6 +162,7 @@ sqlite3 database.

  • datalite package
  • diff --git a/docs/_build/html/objects.inv b/docs/_build/html/objects.inv index dc92aca..91427c5 100644 --- a/docs/_build/html/objects.inv +++ b/docs/_build/html/objects.inv @@ -2,4 +2,6 @@ # Project: Datalite # Version: # The remainder of this file is compressed using zlib. -xڝN0w?!XSڙcu/ncGxC~6윎;Q^ձ(ԷRRu7tLp{fwerXƖo\Ƶ*Z^ڰ=Y8s_"Ua'hTzH:X z gא|@=J%VzYc@ $f_`26 \ No newline at end of file +xڝ=N0bݚi% +hbO ڎ z8 y<%F*R!YY.h ٷRSm';Mp{af9Z+&갍3HUo<چ-ը +X`WF5-,* J,?B;.X$l(k*+mD4V4 =2[l'g~J|08;C6ʆv=8c; ~Qg k    datalite.fetch + + +
      +
    • + datalite.migrations + +
        + datalite.migrations +
    diff --git a/docs/_build/html/searchindex.js b/docs/_build/html/searchindex.js index 3cd1981..c08dfed 100644 --- a/docs/_build/html/searchindex.js +++ b/docs/_build/html/searchindex.js @@ -1 +1 @@ -Search.setIndex({docnames:["datalite","index","modules"],envversion:{"sphinx.domains.c":2,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":3,"sphinx.domains.index":1,"sphinx.domains.javascript":2,"sphinx.domains.math":2,"sphinx.domains.python":2,"sphinx.domains.rst":2,"sphinx.domains.std":1,sphinx:56},filenames:["datalite.rst","index.rst","modules.rst"],objects:{"datalite.fetch":{fetch_all:[0,0,1,""],fetch_equals:[0,0,1,""],fetch_from:[0,0,1,""],fetch_if:[0,0,1,""],fetch_range:[0,0,1,""],fetch_where:[0,0,1,""],is_fetchable:[0,0,1,""]},datalite:{datalite:[0,0,1,""],fetch:[0,1,0,"-"]}},objnames:{"0":["py","function","Python function"],"1":["py","module","Python module"]},objtypes:{"0":"py:function","1":"py:module"},terms:{"class":0,"default":0,"int":0,"new":0,"return":0,The:0,add:0,all:0,ani:0,arg:[],argument:[],bind:[0,1],bool:0,bound:0,callabl:0,can:1,check:0,class_:0,close:0,come:0,common:[],condit:0,content:[],convert:[],count:0,create_entri:0,data:0,databas:[0,1],dataclass:[0,1],datalite_decor:[],db_path:0,decor:[],defin:[],dict:0,dictionari:0,each:0,element:0,element_count:0,fetch:[1,2],fetch_al:0,fetch_equ:0,fetch_from:0,fetch_if:0,fetch_rang:0,fetch_wher:0,fetchabl:0,fetchal:0,field:0,fit:0,from:0,get:[],given:0,ids:0,index:1,insert:[],insert_pagin:[],is_fetch:0,its:0,kwarg:[],librari:1,mean:0,method:0,modifi:[],modul:[1,2],none:0,number:[],obj_id:0,object:0,option:0,overload:0,packag:[1,2],page:[0,1],pagin:[],param:[],paramet:0,path:0,provid:0,python:1,queri:[],rang:0,range_:0,record:0,remove_entri:0,remove_from:[],retriev:0,search:1,simpl:1,sqlite3:[0,1],str:0,submodul:[],taken:0,thei:0,thi:0,tupl:0,type:0,type_overload:0,uniqu:0,update_entri:0,use:1,used:[],valu:0,variabl:0,which:0,whose:0},titles:["datalite package","Welcome to Datalite\u2019s documentation!","datalite"],titleterms:{common:[],content:1,datalit:[0,1,2],datalite_decor:[],document:1,fetch:0,indic:1,modul:0,packag:0,submodul:[],tabl:1,welcom:1}}) \ No newline at end of file +Search.setIndex({docnames:["datalite","index"],envversion:{"sphinx.domains.c":2,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":3,"sphinx.domains.index":1,"sphinx.domains.javascript":2,"sphinx.domains.math":2,"sphinx.domains.python":2,"sphinx.domains.rst":2,"sphinx.domains.std":1,sphinx:56},filenames:["datalite.rst","index.rst"],objects:{"datalite.fetch":{fetch_all:[0,0,1,""],fetch_equals:[0,0,1,""],fetch_from:[0,0,1,""],fetch_if:[0,0,1,""],fetch_range:[0,0,1,""],fetch_where:[0,0,1,""],is_fetchable:[0,0,1,""]},"datalite.migrations":{basic_migrate:[0,0,1,""]},datalite:{datalite:[0,0,1,""],fetch:[0,1,0,"-"],migrations:[0,1,0,"-"]}},objnames:{"0":["py","function","Python function"],"1":["py","module","Python module"]},objtypes:{"0":"py:function","1":"py:module"},terms:{"class":0,"default":0,"function":0,"int":0,"new":0,"return":0,The:0,add:0,all:0,ani:0,arg:[],argument:[],basic_migr:0,bind:[0,1],bool:0,bound:0,callabl:0,can:1,chang:0,check:0,class_:0,close:0,column:0,column_flow:0,column_transf:0,come:0,common:[],compar:0,condit:0,content:[],convert:[],copi:0,count:0,creat:0,create_entri:0,data:0,databas:[0,1],dataclass:[0,1],datalite_decor:[],db_path:0,deal:0,decor:[],defin:[],definit:0,delet:0,dict:0,dictionari:0,each:0,element:0,element_count:0,exist:0,fetch:1,fetch_al:0,fetch_equ:0,fetch_from:0,fetch_if:0,fetch_rang:0,fetch_wher:0,fetchabl:0,fetchal:0,field:0,fit:0,from:0,get:[],given:0,ids:0,index:1,insert:[],insert_pagin:[],is_fetch:0,its:0,kwarg:[],librari:1,longer:0,mean:0,method:0,migrat:1,modifi:[],modul:1,none:0,number:[],obj_id:0,object:0,ones:0,option:0,overload:0,packag:1,page:[0,1],pagin:[],param:[],paramet:0,path:0,previou:0,provid:0,python:1,queri:[],rang:0,range_:0,record:0,remove_entri:0,remove_from:[],retriev:0,schema:0,search:1,show:0,simpl:1,sqlite3:[0,1],str:0,submodul:[],tabl:0,taken:0,thei:0,thi:0,tupl:0,type:0,type_overload:0,uniqu:0,update_entri:0,use:1,used:[],valu:0,variabl:0,when:0,which:0,whose:0},titles:["datalite package","Welcome to Datalite\u2019s documentation!"],titleterms:{common:[],content:1,datalit:[0,1],datalite_decor:[],document:1,fetch:0,indic:1,migrat:0,modul:0,packag:0,submodul:[],tabl:1,welcom:1}}) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 6d69c82..f24a62c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ copyright = '2020, Ege Ozkan' author = 'Ege Ozkan' # The full version, including alpha/beta/rc tags -release = 'v0.5.2' +release = 'v0.5.3' # -- General configuration --------------------------------------------------- diff --git a/docs/datalite.rst b/docs/datalite.rst index 4670c1f..5c6c468 100644 --- a/docs/datalite.rst +++ b/docs/datalite.rst @@ -14,4 +14,10 @@ datalite.fetch module :undoc-members: :show-inheritance: +datalite.migrations module +---------------------------- +.. automodule:: datalite.migrations + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index e45ecfe..0000000 --- a/docs/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -datalite -======== - -.. toctree:: - :maxdepth: 4 - - datalite diff --git a/setup.py b/setup.py index 093f091..28ddb01 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r") as fh: setuptools.setup( name="datalite", # Replace with your own username - version="0.5.2", + version="0.5.3", author="Ege Ozkan", author_email="egeemirozkan24@gmail.com", description="A small package that binds dataclasses to an sqlite database", diff --git a/test/main_tests.py b/test/main_tests.py index 54378a8..c174d07 100644 --- a/test/main_tests.py +++ b/test/main_tests.py @@ -6,6 +6,8 @@ from dataclasses import dataclass, asdict from math import floor from os import remove +from datalite.migrations import basic_migrate, _drop_table + @datalite(db_path='test.db') @dataclass @@ -28,6 +30,17 @@ class FetchClass: def __eq__(self, other): return asdict(self) == asdict(other) +@datalite(db_path='test.db') +@dataclass +class Migrate1: + ordinal: int + + +@datalite(db_path='test.db') +@dataclass +class Migrate2: + cardinal: int + def getValFromDB(obj_id = 1): with connect('test.db') as db: @@ -127,5 +140,25 @@ class DatabaseFetchPaginationCalls(unittest.TestCase): def tearDown(self) -> None: [obj.remove_entry() for obj in self.objs] + +class DatabaseMigration(unittest.TestCase): + def setUp(self) -> None: + self.objs = [Migrate1(i) for i in range(10)] + [obj.create_entry() for obj in self.objs] + + def testBasicMigrate(self): + global Migrate1, Migrate2 + Migrate1 = Migrate2 + Migrate1.__name__ = 'Migrate1' + basic_migrate(Migrate1, {'ordinal': 'cardinal'}) + t_objs = fetch_all(Migrate1) + self.assertEqual([obj.ordinal for obj in self.objs], [obj.cardinal for obj in t_objs]) + + def tearDown(self) -> None: + t_objs = fetch_all(Migrate1) + [obj.remove_entry() for obj in t_objs] + _drop_table('test.db', 'migrate1') + + if __name__ == '__main__': unittest.main()