padmy 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
padmy-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: padmy
3
+ Version: 0.4.0
4
+ Summary:
5
+ Author: andarius
6
+ Author-email: julien.brayere@tracktor.fr
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Provides-Extra: network
12
+ Requires-Dist: Faker (>=13.15.1,<14.0.0)
13
+ Requires-Dist: PyYAML (>=6.0,<7.0)
14
+ Requires-Dist: asyncpg (>=0.27.0,<0.28.0)
15
+ Requires-Dist: dash (>=2.6.0,<3.0.0); extra == "network"
16
+ Requires-Dist: dash-cytoscape (>=0.3.0,<0.4.0); extra == "network"
17
+ Requires-Dist: networkx (>=2.8.5,<3.0.0); extra == "network"
18
+ Requires-Dist: piou (>=0.13.1,<0.14.0)
19
+ Requires-Dist: typing-extensions (>=4.3.0,<5.0.0)
File without changes
@@ -0,0 +1 @@
1
+ from .anonymize import anonymize_db
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ import itertools
3
+ import logging
4
+ import operator
5
+ from functools import partial
6
+ from typing import Any, Iterator
7
+
8
+ import asyncpg
9
+ from faker import Faker
10
+
11
+ from ..config import Config, ConfigTable, FieldType, AnoFields
12
+ from ..db import load_primary_keys, load_columns_type
13
+ from ..utils import get_conn, iterate_pg
14
+
15
+
16
+ def get_update_query(table: str, pks: list[str], fields: list[str],
17
+ field_types: dict):
18
+ _table_keys = pks + fields
19
+ _set_fields = ', '.join(f'{_field} = u2.{_field}'
20
+ for _field in fields)
21
+ _values = ', '.join(f'${i + 1}::{field_types[k]}' for i, k in enumerate(_table_keys))
22
+ _where = ' and '.join(f'u2.{_pk} = u.{_pk}' for _pk in pks)
23
+
24
+ query = f"""
25
+ UPDATE {table} as u
26
+ SET
27
+ {_set_fields}
28
+ from (values
29
+ ({_values})
30
+ ) as u2({', '.join(_table_keys)})
31
+ where {_where}
32
+ """
33
+ return query
34
+
35
+
36
+ def _get_fake_value(faker: Faker, field: FieldType,
37
+ extra_fields: dict | None = None) -> Any:
38
+ _extra_fields = extra_fields or {}
39
+ match field:
40
+ case 'EMAIL':
41
+ return faker.email(**_extra_fields)
42
+ case _:
43
+ raise ValueError(f'Got unimplemented field type {field!r}')
44
+
45
+
46
+ def gen_mock_data(faker: Faker,
47
+ fields: list[AnoFields],
48
+ size: int) -> Iterator[dict]:
49
+ for _ in range(size):
50
+ yield {
51
+ v.column: _get_fake_value(faker, v.type, v.extra_args) for v in fields
52
+ }
53
+
54
+
55
+ def dict_to_tuple(d: dict, fields: list[str]) -> tuple:
56
+ return tuple(d[k] for k in fields)
57
+
58
+
59
+ async def anonymize_table(conn: asyncpg.Connection,
60
+ table: ConfigTable,
61
+ pks: list[str],
62
+ faker: Faker,
63
+ *,
64
+ chunk_size: int = 1_000):
65
+ if table.fields is None:
66
+ raise ValueError('Fields must not be empty')
67
+ if not pks:
68
+ raise ValueError(f'No PKs found for {table.full_name!r}')
69
+
70
+ query = f"SELECT {', '.join(pks)} from {table.schema}.{table.table}"
71
+
72
+ fields = [x.column for x in table.fields]
73
+ fields_types = await load_columns_type(conn, table.schema, table.table,
74
+ pks + fields)
75
+ update_query = get_update_query(table.full_name, pks, fields,
76
+ fields_types)
77
+
78
+ async with conn.transaction():
79
+ async for chunk in iterate_pg(conn, query, chunk_size=chunk_size):
80
+ mock_data = gen_mock_data(faker, fields=table.fields, size=chunk_size)
81
+ new_data = [dict_to_tuple({**c, **m}, pks + fields) for c, m in zip(chunk, mock_data)]
82
+ await conn.executemany(update_query, new_data)
83
+
84
+
85
+ async def anonymize_db(pool: asyncpg.Pool, config: Config, faker: Faker):
86
+ _tables_to_anonymize = [_table for _table in config.tables if _table.has_ano_fields]
87
+
88
+ if not _tables_to_anonymize:
89
+ logging.info('No tables found to anonymize in config file')
90
+ return
91
+
92
+ async with pool.acquire() as conn:
93
+ pks = await load_primary_keys(conn, list({_table.schema for _table in _tables_to_anonymize}))
94
+
95
+ _pks = {_table_name: list(_table_pks) for _table_name, _table_pks in
96
+ itertools.groupby(pks, operator.attrgetter('full_name'))}
97
+
98
+ await asyncio.gather(*[get_conn(pool, partial(anonymize_table,
99
+ table=_table,
100
+ pks=[x.column_name for x in _pks[_table.full_name]],
101
+ faker=faker))
102
+ for _table in _tables_to_anonymize])
@@ -0,0 +1,113 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+ # else:
4
+ # from typing import Self
5
+ from typing import Literal
6
+
7
+ import yaml
8
+
9
+ # if sys.version_info.minor < 11 and sys.version_info.major >= 3:
10
+
11
+ FieldType = Literal['EMAIL']
12
+
13
+ SampleType = float | int
14
+
15
+
16
+ def _check_sample_size(sample: SampleType | None):
17
+ if sample is None:
18
+ return
19
+ if sample < 0 or sample > 100:
20
+ raise ValueError(f'Sample must be a value between 0 and 100 (got {sample})')
21
+
22
+
23
+ @dataclass
24
+ class AnoFields:
25
+ column: str
26
+ type: FieldType
27
+ extra_args: dict | None = None
28
+
29
+ @classmethod
30
+ def load(cls, data: dict):
31
+
32
+ if len(data) == 1:
33
+ column = next(iter(data))
34
+ _type = data[column]
35
+ extra_args = None
36
+ else:
37
+ column = data.pop('column')
38
+ _type = data.pop('type')
39
+ extra_args = data if data else None
40
+
41
+ return AnoFields(column=column,
42
+ type=_type,
43
+ extra_args=extra_args)
44
+
45
+
46
+ @dataclass
47
+ class ConfigTable:
48
+ schema: str
49
+ table: str
50
+ sample: SampleType | None = None
51
+
52
+ fields: list[AnoFields] = field(default_factory=list)
53
+
54
+ ignore: bool = False
55
+
56
+ def __post_init__(self):
57
+ _check_sample_size(self.sample)
58
+
59
+ @property
60
+ def full_name(self):
61
+ return f'{self.schema}.{self.table}'
62
+
63
+ @property
64
+ def has_ano_fields(self):
65
+ return self.fields is not None
66
+
67
+ @classmethod
68
+ def load(cls, table: dict):
69
+ _fields_any: dict | list = table.pop('fields', [])
70
+ _fields: list = [_fields_any] if isinstance(_fields_any, dict) else _fields_any
71
+
72
+ return cls(**table,
73
+ fields=[AnoFields.load(_field) for _field in _fields])
74
+
75
+
76
+ @dataclass
77
+ class ConfigSchema:
78
+ schema: str
79
+ sample: SampleType | None = None
80
+
81
+ def __post_init__(self):
82
+ _check_sample_size(self.sample)
83
+
84
+ @classmethod
85
+ def load(cls, v: str | dict):
86
+ if isinstance(v, str):
87
+ return cls(v)
88
+ else:
89
+ return cls(v['name'], v.get('sample'))
90
+
91
+
92
+ @dataclass
93
+ class Config:
94
+ sample: SampleType | None = None
95
+ schemas: list[ConfigSchema] = field(default_factory=list)
96
+ tables: list[ConfigTable] = field(default_factory=list)
97
+
98
+ def __post_init__(self):
99
+ _check_sample_size(self.sample)
100
+
101
+ @classmethod
102
+ def load(cls, sample: SampleType, schemas: list[str]):
103
+ _schemas = [ConfigSchema(x) for x in schemas]
104
+ return cls(sample=sample, schemas=_schemas)
105
+
106
+ @classmethod
107
+ def load_from_file(cls, path: Path):
108
+ with path.open('r') as f:
109
+ config = yaml.load(f, Loader=yaml.Loader)
110
+ schemas = [ConfigSchema.load(schema) for schema in config.pop('schemas', [])]
111
+ tables = [ConfigTable.load(table) for table in config.pop('tables', [])]
112
+
113
+ return cls(**config, schemas=schemas, tables=tables)
@@ -0,0 +1,307 @@
1
+ import asyncio
2
+ import functools
3
+ from dataclasses import dataclass, field
4
+
5
+ import asyncpg
6
+ from rich.console import Console
7
+ from rich.table import Table as RTable
8
+ from typing_extensions import Self
9
+
10
+ from padmy.config import Config, SampleType
11
+ from padmy.utils import get_first, get_conn
12
+
13
+
14
+ # if sys.version_info.minor < 11 and sys.version_info.major >= 3:
15
+ # else:
16
+ # from typing import Self
17
+
18
+
19
+ def _get_full_name(schema: str | None, table: str | None) -> str:
20
+ if schema is None or table is None:
21
+ raise ValueError('schema and table must not be empty')
22
+ return f'{schema}.{table}'
23
+
24
+
25
+ @dataclass
26
+ class FKConstraint:
27
+ column_name: str
28
+ constraint_name: str
29
+
30
+ # references
31
+ foreign_schema: str
32
+ foreign_table: str
33
+ foreign_column_name: str
34
+
35
+ table: str | None = None
36
+ schema: str | None = None
37
+
38
+ @property
39
+ def foreign_full_name(self):
40
+ return _get_full_name(self.foreign_schema, self.foreign_table)
41
+
42
+ @property
43
+ def full_name(self):
44
+ return _get_full_name(self.schema, self.table)
45
+
46
+
47
+ @dataclass
48
+ class PKConstraint:
49
+ column_name: str
50
+ table: str
51
+ schema: str
52
+
53
+ @property
54
+ def full_name(self):
55
+ return _get_full_name(self.schema, self.table)
56
+
57
+
58
+ @dataclass(eq=False)
59
+ class Table:
60
+ schema: str
61
+ table: str
62
+
63
+ _count: int | None = None # field(init=False)
64
+
65
+ foreign_keys: list[FKConstraint] = field(default_factory=list)
66
+ primary_keys: list[PKConstraint] = field(default_factory=list)
67
+
68
+ parent_tables: set[Self] = field(default_factory=set)
69
+ child_tables: set[Self] = field(default_factory=set)
70
+
71
+ # Has already been sampled and it's temporary table has been created
72
+ has_been_processed: bool = False
73
+
74
+ # Sample size
75
+ sample_size: int | None = None
76
+
77
+ @property
78
+ def parent_tables_safe(self):
79
+ # return self.parent_tables - {self}
80
+ return {x for x in self.parent_tables if x.full_name != self.full_name}
81
+
82
+ @property
83
+ def child_tables_safe(self):
84
+ # return self.child_tables - {self}
85
+ return {x for x in self.child_tables if x.full_name != self.full_name}
86
+
87
+ @property
88
+ def full_name(self):
89
+ return _get_full_name(self.schema, self.table)
90
+
91
+ @property
92
+ def tmp_name(self):
93
+ return f'_{self.schema}_{self.table}_tmp'
94
+
95
+ @property
96
+ def has_parent(self):
97
+ return len(self.parent_tables_safe) > 0
98
+
99
+ @property
100
+ def has_children(self):
101
+ return len(self.child_tables_safe) > 0
102
+
103
+ @property
104
+ def children_has_been_processed(self):
105
+ return all(_child.has_been_processed for _child in self.child_tables_safe)
106
+
107
+ @property
108
+ def count(self) -> int:
109
+ if self._count is None:
110
+ raise ValueError('Count must be loaded first')
111
+ return self._count
112
+
113
+ @count.setter
114
+ def count(self, v: int):
115
+ self._count = v
116
+
117
+ async def load_count(self, conn: asyncpg.Connection):
118
+ self._count = await conn.fetchval(f'SELECT count(*) from {self.full_name}')
119
+
120
+ def __eq__(self, other: Self):
121
+ for k in ['full_name', 'has_been_processed']:
122
+ if getattr(self, k) != getattr(other, k):
123
+ return False
124
+ return True
125
+
126
+ def __hash__(self):
127
+ return hash((getattr(self, x) for x in ['full_name']))
128
+
129
+ def __repr__(self):
130
+ return f'Table(full_name={self.full_name!r} ' \
131
+ f'count={self._count} ' \
132
+ f'foreign_keys={len(self.foreign_keys)} ' \
133
+ f'parents={len(self.parent_tables)} ' \
134
+ f'children={len(self.child_tables)}' \
135
+ f')'
136
+
137
+ def __post_init__(self):
138
+ for fk in self.foreign_keys:
139
+ fk.table = self.table
140
+ fk.schema = self.schema
141
+
142
+
143
+ async def get_tables(conn: asyncpg.Connection, schemas: list[str]):
144
+ query = """
145
+ select
146
+ table_schema as schema, table_name as table
147
+ from information_schema.tables
148
+ where table_schema = ANY ($1::text[]) and
149
+ table_type = 'BASE TABLE'
150
+ """
151
+ data = await conn.fetch(query, schemas)
152
+ return [Table(**x) for x in data]
153
+
154
+
155
+ SCHEMA_FK_QUERY = """
156
+ SELECT
157
+ tc.table_schema as schema,
158
+ tc.constraint_name,
159
+ tc.table_name as table,
160
+ kcu.column_name,
161
+ ccu.table_schema AS foreign_schema,
162
+ ccu.table_name AS foreign_table,
163
+ ccu.column_name AS foreign_column_name
164
+ FROM
165
+ information_schema.table_constraints AS tc
166
+ JOIN information_schema.key_column_usage AS kcu
167
+ ON tc.constraint_name = kcu.constraint_name
168
+ AND tc.table_schema = kcu.table_schema
169
+ JOIN information_schema.constraint_column_usage AS ccu
170
+ ON ccu.constraint_name = tc.constraint_name
171
+ AND ccu.table_schema = tc.table_schema
172
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ANY ($1::text[]);
173
+ """
174
+
175
+ SCHEMA_PK_QUERY = """
176
+ SELECT
177
+ tc.table_schema as schema,
178
+ tc.table_name as table,
179
+ c.column_name
180
+ FROM information_schema.table_constraints tc
181
+ JOIN information_schema.constraint_column_usage AS ccu USING (constraint_schema, constraint_name)
182
+ JOIN information_schema.columns AS c ON c.table_schema = tc.constraint_schema
183
+ AND tc.table_name = c.table_name AND ccu.column_name = c.column_name
184
+ WHERE constraint_type = 'PRIMARY KEY' and tc.table_schema = ANY ($1::text[]);
185
+ """
186
+
187
+
188
+ async def load_foreign_keys(conn: asyncpg.Connection, schemas: list[str]):
189
+ data = await conn.fetch(SCHEMA_FK_QUERY, schemas)
190
+ return [FKConstraint(**x) for x in data]
191
+
192
+
193
+ async def load_primary_keys(conn: asyncpg.Connection, schemas: list[str]):
194
+ data = await conn.fetch(SCHEMA_PK_QUERY, schemas)
195
+ return [PKConstraint(**x) for x in data]
196
+
197
+
198
+ GET_COLUMNS_TYPE_QUERY = """
199
+ select
200
+ column_name, data_type
201
+ from information_schema.columns
202
+ where table_schema = $1 and
203
+ table_name = $2 and
204
+ column_name = ANY ($3::text[])
205
+ """
206
+
207
+
208
+ async def load_columns_type(conn: asyncpg.Connection, schema: str,
209
+ table: str,
210
+ columns: list[str]):
211
+ data = await conn.fetch(GET_COLUMNS_TYPE_QUERY, schema,
212
+ table, columns)
213
+ return functools.reduce(lambda p, n: {**p, **{n['column_name']: n['data_type']}},
214
+ data, {})
215
+
216
+
217
+ @dataclass
218
+ class Database:
219
+ name: str
220
+ tables: list[Table] = field(default_factory=list)
221
+
222
+ async def explore(self, pool: asyncpg.Pool, schemas: list[str], *, load_count: bool = True):
223
+ async with pool.acquire() as conn:
224
+ self.tables = await get_tables(conn, schemas)
225
+ fks = await load_foreign_keys(conn, schemas)
226
+ pks = await load_primary_keys(conn, schemas)
227
+
228
+ _tables: dict[str, Table] = {_table.full_name: _table for _table in self.tables}
229
+
230
+ for _pk in pks:
231
+ _tables[_pk.full_name].primary_keys.append(_pk)
232
+
233
+ for _fk in fks:
234
+ _tables[_fk.full_name].foreign_keys.append(_fk)
235
+ _tables[_fk.full_name].parent_tables.add(_tables[_fk.foreign_full_name])
236
+ _tables[_fk.foreign_full_name].child_tables.add(_tables[_fk.full_name])
237
+
238
+ if load_count:
239
+ await asyncio.gather(*[
240
+ get_conn(pool, table.load_count) for table in self.tables
241
+ ])
242
+
243
+ def load_config(self, config: Config):
244
+ """
245
+ Loads the sample sizes for each tables from the config file.
246
+ Tables need to have been loaded first
247
+ """
248
+ _schemas: dict[str, SampleType | None] = {schema.schema: schema.sample for schema in config.schemas}
249
+ _tables: dict[str, SampleType | None] = {f'{_table.schema}.{_table.table}': _table.sample for _table in
250
+ config.tables}
251
+ for _table in self.tables:
252
+ _schema_sample = _schemas.get(_table.schema)
253
+ _table_sample = _tables.get(_table.full_name)
254
+ _sample = get_first(_table_sample,
255
+ _schema_sample,
256
+ config.sample,
257
+ fn=lambda x: x is not None)
258
+ if _sample is None:
259
+ raise ValueError('_sample must not be empty')
260
+ _table.sample_size = int(_sample)
261
+
262
+
263
+ def pretty_print_stats(database: Database):
264
+ table = RTable(title=f"Stats for {database.name}")
265
+
266
+ table.add_column("Table", justify="left", style="cyan")
267
+ table.add_column("Count", justify="right", style="green")
268
+ table.add_column("# FKs", justify="right", style="magenta")
269
+ table.add_column("# Parents", justify="right", style="magenta")
270
+ table.add_column("# Children", justify="right", style="magenta")
271
+
272
+ for _table in sorted(database.tables, key=lambda x: x.full_name):
273
+ table.add_row(_table.full_name, str(_table.count),
274
+ str(len(_table.foreign_keys)),
275
+ str(len(_table.parent_tables)),
276
+ str(len(_table.child_tables))
277
+ )
278
+
279
+ console = Console()
280
+ console.print(table)
281
+
282
+
283
+ def pprint_compared_dbs(db_1: Database, db_2: Database):
284
+ table = RTable(title=f"Comparing {db_1.name} and {db_2.name}")
285
+
286
+ table.add_column("Table", justify="left", style="blue")
287
+ table.add_column(f"Count {db_1.name!r}", justify="right", style="cyan")
288
+ table.add_column(f"Count {db_2.name!r}", justify="right", style="cyan")
289
+ table.add_column("Diff", justify="right", style="green")
290
+
291
+ tables_1, tables_2 = sorted(db_1.tables, key=lambda x: x.full_name), sorted(db_2.tables, key=lambda x: x.full_name)
292
+
293
+ for _table1, _table2 in zip(tables_1, tables_2):
294
+ perc_diff = 100 if _table2.count == 0 else int(_table2.count * 100 / _table1.count)
295
+ if perc_diff > 0:
296
+ pass
297
+ elif perc_diff < 0:
298
+ pass
299
+ else:
300
+ pass
301
+ table.add_row(_table1.full_name,
302
+ str(_table1.count),
303
+ str(_table2.count),
304
+ f'{perc_diff}%')
305
+
306
+ console = Console()
307
+ console.print(table)
@@ -0,0 +1,11 @@
1
+ import os
2
+
3
+ PG_DATABASE = os.getenv('PG_DATABASE', 'postgres')
4
+ PG_HOST = os.getenv('PG_HOST', 'localhost')
5
+ PG_PORT = int(os.getenv('PG_PORT', '5432'))
6
+ PG_USER = os.getenv('PG_USER', 'postgres')
7
+ PG_PASSWORD = os.getenv('PG_PASSWORD', 'postgres')
8
+
9
+ # Migration
10
+ SQL_DIR = os.getenv('SQL_DIR')
11
+ MIGRATION_DIR = os.getenv('MIGRATION_DIR')
@@ -0,0 +1,13 @@
1
+ import logging
2
+ from rich.logging import RichHandler
3
+
4
+ logs = logging.getLogger('padmy')
5
+
6
+
7
+ def setup_logging(level: int):
8
+ logging.basicConfig(
9
+ datefmt="%H:%M:%S",
10
+ format="%(message)s",
11
+ handlers=[RichHandler(rich_tracebacks=False, show_path=False)]
12
+ )
13
+ logs.setLevel(level)
@@ -0,0 +1,3 @@
1
+ from .migration import migrate_down, migrate_up, migrate_verify, migrate_setup
2
+ from .create_files import create_new_migration
3
+ from .run import migration
@@ -0,0 +1,57 @@
1
+ import textwrap
2
+ import time
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+ from rich.markup import escape
8
+ from rich.prompt import Prompt
9
+
10
+ from padmy.logs import logs
11
+ from .utils import get_git_email, get_files, iter_migration_files
12
+
13
+ _CONSOLE = Console(markup=True, highlight=False)
14
+
15
+
16
+ def _get_user_email() -> str | None:
17
+ default_author = get_git_email()
18
+ author = Prompt.ask("[blue]Author[/blue]", default=default_author,
19
+ console=_CONSOLE)
20
+ return author
21
+
22
+
23
+ def _get_last_migration_name(folder: Path) -> str | None:
24
+ """ Returns the most recent migration files"""
25
+ files = get_files(reverse=True, folder=folder)
26
+ if not files:
27
+ return None
28
+ up_file, down_file = next(iter_migration_files(files))
29
+ return up_file.path.name
30
+
31
+
32
+ def create_new_migration(folder: Path):
33
+ """
34
+ Creates 2 new files, up and down
35
+ """
36
+ folder.mkdir(exist_ok=True, parents=True)
37
+
38
+ _base_name = f'{int(time.time())}-{str(uuid.uuid4())[:8]}'
39
+ _CONSOLE.print(f'\nCreating new migration file ([green]{escape(_base_name)}[/green]):\n')
40
+
41
+ last_migration = _get_last_migration_name(folder)
42
+ logs.debug(f'Last migration files: {last_migration}')
43
+ author = _get_user_email()
44
+ logs.debug(f'User email: {author}')
45
+
46
+ up_file = folder / Path(f'{_base_name}-up.sql')
47
+ down_file = folder / Path(f'{_base_name}-down.sql')
48
+
49
+ file_header = textwrap.dedent(f"""
50
+ -- Prev-file: {last_migration or ''}
51
+ -- Author: {author or ''}
52
+ """).strip()
53
+
54
+ up_file.write_text(file_header)
55
+ down_file.write_text(file_header)
56
+
57
+ _CONSOLE.print('\nNew files created!\n')
@@ -0,0 +1,23 @@
1
+ SET SCHEMA 'public';
2
+
3
+ DO
4
+ $$
5
+ BEGIN
6
+ CREATE TYPE MIGRATION_TYPE AS ENUM (
7
+ 'up',
8
+ 'down'
9
+ );
10
+ EXCEPTION
11
+ WHEN duplicate_object THEN null;
12
+ END
13
+ $$;
14
+
15
+ CREATE TABLE IF NOT EXISTS public.migration
16
+ (
17
+ id serial PRIMARY KEY NOT NULL,
18
+ applied_at timestamp NOT NULL DEFAULT now(),
19
+ migration_type MIGRATION_TYPE NOT NULL,
20
+ file_name text NOT NULL,
21
+ file_ts TIMESTAMP NOT NULL,
22
+ file_id varchar(10) NOT NULL
23
+ );