├── test ├── __init__.py ├── test_blueprints │ ├── __init__.py │ ├── test_expression.py │ ├── test_project.py │ ├── test_index.py │ ├── test_enum.py │ ├── test_note.py │ ├── test_table_group.py │ ├── test_reference.py │ ├── test_sticky_note.py │ └── test_column.py ├── test_classes │ ├── __init__.py │ ├── test_expression.py │ ├── test_project.py │ ├── test_note.py │ ├── test_index.py │ ├── test_sticky_note.py │ ├── test_base.py │ ├── test_table_group.py │ ├── test_enum.py │ └── test_reference.py ├── test_data │ ├── .gitignore │ ├── docs │ │ ├── project.dbml │ │ ├── table_notes.dbml │ │ ├── table_alias.dbml │ │ ├── table_definition.dbml │ │ ├── note_definition.dbml │ │ ├── sticky_notes.dbml │ │ ├── column_settings.dbml │ │ ├── relationship_settings.dbml │ │ ├── default_value.dbml │ │ ├── relationships_composite.dbml │ │ ├── column_notes.dbml │ │ ├── relationships_1.dbml │ │ ├── enum_definition.dbml │ │ ├── relationships_2.dbml │ │ ├── example.dbml │ │ ├── index_definition.dbml │ │ ├── project_notes.dbml │ │ └── table_group.dbml │ ├── wrong_inline_ref_table.dbml │ ├── wrong_inline_ref_column.dbml │ ├── wrong_index.dbml │ ├── editing.dbml │ ├── relationships_composite.dbml │ ├── relationships_aliases.dbml │ ├── integration1.dbml │ ├── integration1.sql │ ├── dbml_schema_def.dbml │ ├── general.dbml │ └── notes.dbml ├── test_definitions │ ├── __init__.py │ ├── test_generic.py │ ├── test_sticky_note.py │ ├── test_project.py │ ├── test_table_group.py │ ├── test_common.py │ └── test_enum.py ├── test_renderer │ ├── __init__.py │ ├── test_dbml │ │ ├── __init__.py │ │ ├── test_expression.py │ │ ├── test_sticky_note.py │ │ ├── test_utils.py │ │ ├── test_renderer.py │ │ ├── test_note.py │ │ ├── test_enum.py │ │ ├── test_table_group.py │ │ ├── test_project.py │ │ ├── test_index.py │ │ ├── test_table.py │ │ └── test_reference.py │ ├── test_sql │ │ ├── __init__.py │ │ └── test_default │ │ │ ├── __init__.py │ │ │ ├── test_expression.py │ │ │ ├── test_renderer.py │ │ │ ├── test_enum.py │ │ │ ├── test_column.py │ │ │ ├── test_note.py │ │ │ ├── test_utils.py │ │ │ └── test_index.py │ └── test_base.py ├── utils.py ├── test_doctest.py ├── test_editing.py ├── test_integration.py ├── test_tools.py └── conftest.py ├── pydbml ├── _classes │ ├── __init__.py │ ├── expression.py │ ├── note.py │ ├── sticky_note.py │ ├── project.py │ ├── table_group.py │ ├── base.py │ ├── index.py │ ├── enum.py │ ├── column.py │ └── reference.py ├── renderer │ ├── __init__.py │ ├── dbml │ │ ├── __init__.py │ │ └── default │ │ │ ├── expression.py │ │ │ ├── note.py │ │ │ ├── __init__.py │ │ │ ├── sticky_note.py │ │ │ ├── renderer.py │ │ │ ├── utils.py │ │ │ ├── table_group.py │ │ │ ├── enum.py │ │ │ ├── project.py │ │ │ ├── index.py │ │ │ ├── table.py │ │ │ ├── column.py │ │ │ └── reference.py │ ├── sql │ │ ├── __init__.py │ │ └── default │ │ │ ├── expression.py │ │ │ ├── __init__.py │ │ │ ├── renderer.py │ │ │ ├── enum.py │ │ │ ├── utils.py │ │ │ ├── note.py │ │ │ ├── column.py │ │ │ ├── index.py │ │ │ ├── reference.py │ │ │ └── table.py │ └── base.py ├── definitions │ ├── __init__.py │ ├── sticky_note.py │ ├── common.py │ ├── generic.py │ ├── project.py │ ├── table_group.py │ ├── enum.py │ ├── index.py │ ├── table.py │ ├── column.py │ └── reference.py ├── parser │ └── __init__.py ├── constants.py ├── __init__.py ├── exceptions.py ├── classes │ └── __init__.py └── tools.py ├── test.sh ├── .gitignore ├── coverage.svg ├── LICENSE ├── setup.py ├── test_schema.dbml ├── docs ├── properties.md └── creating_schema.md └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/_classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/renderer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/definitions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_blueprints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_classes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_data/.gitignore: -------------------------------------------------------------------------------- 1 | !*.dbml -------------------------------------------------------------------------------- /test/test_definitions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_renderer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydbml/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import PyDBML 2 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | pytest --doctest-glob="*.md" &&\ 2 | mypy pydbml --ignore-missing-imports 3 | -------------------------------------------------------------------------------- /pydbml/constants.py: -------------------------------------------------------------------------------- 1 | ONE_TO_MANY = '<' 2 | MANY_TO_ONE = '>' 3 | ONE_TO_ONE = '-' 4 | MANY_TO_MANY = '<>' 5 | -------------------------------------------------------------------------------- /pydbml/__init__.py: -------------------------------------------------------------------------------- 1 | from . import _classes 2 | from .parser import PyDBML 3 | from .database import Database 4 | -------------------------------------------------------------------------------- /test/test_data/docs/project.dbml: -------------------------------------------------------------------------------- 1 | Project project_name { 2 | database_type: 'PostgreSQL' 3 | Note: 'Description of the project' 4 | } 5 | -------------------------------------------------------------------------------- /test/test_data/docs/table_notes.dbml: -------------------------------------------------------------------------------- 1 | Table users { 2 | id integer 3 | status varchar [note: 'status'] 4 | 5 | Note: 'Stores user data' 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .DS_Store 4 | *.dbml 5 | *.log 6 | !test_schema.dbml 7 | build 8 | dist 9 | pydbml.egg-info 10 | .mypy_cache 11 | .coverage 12 | .eggs 13 | .idea 14 | -------------------------------------------------------------------------------- /test/test_data/docs/table_alias.dbml: -------------------------------------------------------------------------------- 1 | Table very_long_user_table as U { 2 | id integer 3 | } 4 | 5 | Table posts { 6 | id integer 7 | user_id integer 8 | } 9 | 10 | Ref: U.id < posts.user_id 11 | -------------------------------------------------------------------------------- /test/test_data/docs/table_definition.dbml: -------------------------------------------------------------------------------- 1 | Table table_name { 2 | column_name column_type 3 | "example" Example 4 | json_column JSON 5 | jsonb_column JSONB 6 | decimal_column decimal(1,2) 7 | } 8 | 9 | -------------------------------------------------------------------------------- /test/test_data/docs/note_definition.dbml: -------------------------------------------------------------------------------- 1 | Table users { 2 | id int [pk] 3 | name varchar 4 | 5 | Note: 'This is a note of this table' 6 | // or 7 | Note { 8 | 'This is a note of this table' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/test_data/docs/sticky_notes.dbml: -------------------------------------------------------------------------------- 1 | Note single_line_note { 2 | 'This is a single line note' 3 | } 4 | 5 | Note multiple_lines_note { 6 | ''' 7 | This is a multiple lines note 8 | This string can spans over multiple lines. 9 | ''' 10 | } 11 | -------------------------------------------------------------------------------- /test/test_data/docs/column_settings.dbml: -------------------------------------------------------------------------------- 1 | Table buildings { 2 | address varchar(255) [unique, not null, note: 'to include unit number'] 3 | id integer [ pk, unique, default: 123, note: 'Number' ] 4 | nullable string [null] 5 | counter integer [increment] 6 | } 7 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_expression.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Expression 2 | from pydbml.renderer.dbml.default import render_expression 3 | 4 | 5 | def test_render_expression(expression1: Expression) -> None: 6 | assert render_expression(expression1) == "`SUM(amount)`" 7 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_expression.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Expression 2 | from pydbml.renderer.sql.default import render_expression 3 | 4 | 5 | def test_render_expression(expression1: Expression): 6 | assert render_expression(expression1) == '(SUM(amount))' 7 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/expression.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Expression 2 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 3 | 4 | 5 | @DefaultSQLRenderer.renderer_for(Expression) 6 | def render_expression(model: Expression) -> str: 7 | return f'({model.text})' 8 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/expression.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Expression 2 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 3 | 4 | 5 | @DefaultDBMLRenderer.renderer_for(Expression) 6 | def render_expression(model: Expression) -> str: 7 | return f'`{model.text}`' 8 | -------------------------------------------------------------------------------- /test/test_data/docs/relationship_settings.dbml: -------------------------------------------------------------------------------- 1 | Table products { 2 | merchant_id integer 3 | country_code char 4 | } 5 | 6 | Table merchants { 7 | id integer 8 | country_code char 9 | } 10 | 11 | Ref: products.merchant_id > merchants.id [delete: cascade, update: no action] 12 | -------------------------------------------------------------------------------- /test/test_data/docs/default_value.dbml: -------------------------------------------------------------------------------- 1 | Table users { 2 | id integer [primary key] 3 | username varchar(255) [not null, unique] 4 | full_name varchar(255) [not null] 5 | gender varchar(1) [default: 'm'] 6 | created_at timestamp [default: `now()`] 7 | rating integer [default: 10] 8 | } 9 | -------------------------------------------------------------------------------- /test/test_data/docs/relationships_composite.dbml: -------------------------------------------------------------------------------- 1 | Table merchant_periods { 2 | merchant_id integer 3 | country_code char 4 | } 5 | 6 | Table merchants { 7 | id integer 8 | country_code char 9 | } 10 | 11 | Ref: merchant_periods.(merchant_id, country_code) > merchants.(id, country_code) 12 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | from typing import Optional 3 | 4 | 5 | DEFAULT_OPTIONS = { 6 | 'reformat_notes': True, 7 | } 8 | 9 | def mock_parser(options: Optional[dict] = None): 10 | if options is None: 11 | options = dict(DEFAULT_OPTIONS) 12 | return Mock(options=options) 13 | -------------------------------------------------------------------------------- /test/test_data/docs/column_notes.dbml: -------------------------------------------------------------------------------- 1 | Table users { 2 | id int [pk] 3 | name varchar 4 | column_name column_type [note: 'replace text here'] 5 | status varchar [ 6 | note: ''' 7 | 💸 1 = processing, 8 | ✔️ 2 = shipped, 9 | ❌ 3 = cancelled, 10 | 😔 4 = refunded 11 | '''] 12 | Note: 'Stores user data' 13 | } 14 | -------------------------------------------------------------------------------- /test/test_data/docs/relationships_1.dbml: -------------------------------------------------------------------------------- 1 | Table posts { 2 | id integer [primary key] 3 | user_id integer [ref: > users.id] // many-to-one 4 | } 5 | 6 | Table reviews { 7 | id integer [primary key] 8 | user_id integer [ref: > users.id] // many-to-one 9 | } 10 | 11 | // or this 12 | Table users { 13 | id integer 14 | } 15 | -------------------------------------------------------------------------------- /test/test_classes/test_expression.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Expression 4 | 5 | 6 | def test_str(expression1: Expression) -> None: 7 | assert str(expression1) == 'SUM(amount)' 8 | 9 | 10 | def test_repr(expression1: Expression) -> None: 11 | assert repr(expression1) == "Expression('SUM(amount)')" 12 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/__init__.py: -------------------------------------------------------------------------------- 1 | from .renderer import DefaultSQLRenderer 2 | from .column import render_column 3 | from .enum import render_enum, render_enum_item 4 | from .expression import render_expression 5 | from .index import render_index 6 | from .note import render_note 7 | from .reference import render_reference 8 | from .table import render_table 9 | -------------------------------------------------------------------------------- /test/test_data/wrong_inline_ref_table.dbml: -------------------------------------------------------------------------------- 1 | table ids as ii [ 2 | headercolor: #ccc, 3 | note: "headernote"] 4 | { 5 | id integer 6 | note: "bodynote" 7 | } 8 | 9 | Table bookings as bb [headercolor: #cccccc] { 10 | id integer 11 | country varchar [NOT NULL, ref: > wrong_table.id] 12 | booking_date date unique pk 13 | created_at timestamp 14 | } 15 | -------------------------------------------------------------------------------- /test/test_data/docs/enum_definition.dbml: -------------------------------------------------------------------------------- 1 | enum job_status { 2 | created [note: 'Waiting to be processed'] 3 | running 4 | done 5 | failure 6 | } 7 | 8 | enum grade { 9 | "A+" 10 | "A" 11 | "A-" 12 | "Not Yet Set" 13 | } 14 | 15 | 16 | 17 | Table jobs { 18 | id integer 19 | status job_status 20 | grade grade 21 | } 22 | 23 | -------------------------------------------------------------------------------- /test/test_data/wrong_inline_ref_column.dbml: -------------------------------------------------------------------------------- 1 | table ids as ii [ 2 | headercolor: #ccc, 3 | note: "headernote"] 4 | { 5 | id integer 6 | note: "bodynote" 7 | } 8 | 9 | Table bookings as bb [headercolor: #cccccc] { 10 | id integer 11 | country varchar [NOT NULL, ref: > ids.wrong_column] 12 | booking_date date unique pk 13 | created_at timestamp 14 | } 15 | -------------------------------------------------------------------------------- /test/test_data/docs/relationships_2.dbml: -------------------------------------------------------------------------------- 1 | Table posts { 2 | id integer [primary key] 3 | user_id integer 4 | } 5 | 6 | Table reviews { 7 | id integer [primary key] 8 | user_id integer 9 | } 10 | 11 | // or this 12 | Table users { 13 | id integer [ref: < posts.user_id, ref: < reviews.user_id] // one to many 14 | } 15 | 16 | // The space after '<' is optional 17 | -------------------------------------------------------------------------------- /test/test_data/docs/example.dbml: -------------------------------------------------------------------------------- 1 | Table users { 2 | id integer 3 | username varchar 4 | role varchar 5 | created_at timestamp 6 | } 7 | 8 | Table posts { 9 | id integer [primary key] 10 | title varchar 11 | body text [note: 'Content of the post'] 12 | user_id integer 13 | created_at timestamp 14 | } 15 | 16 | Ref: posts.user_id > users.id // many-to-one 17 | -------------------------------------------------------------------------------- /test/test_data/wrong_index.dbml: -------------------------------------------------------------------------------- 1 | table ids as ii [ 2 | headercolor: #ccc, 3 | note: "headernote"] 4 | { 5 | id integer 6 | note: "bodynote" 7 | } 8 | 9 | Table bookings as bb [headercolor: #cccccc] { 10 | id integer 11 | country varchar [NOT NULL, ref: > ids.id] 12 | booking_date date unique pk 13 | created_at timestamp 14 | indexes { 15 | (id, wrong_column) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/test_classes/test_project.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Note 2 | from pydbml.classes import Project 3 | 4 | 5 | def test_note_property(): 6 | note1 = Note('column note') 7 | p = Project('myproject') 8 | p.note = note1 9 | assert p.note.parent is p 10 | 11 | 12 | def test_repr() -> None: 13 | project = Project('myproject') 14 | assert repr(project) == "" 15 | 16 | -------------------------------------------------------------------------------- /test/test_blueprints/test_expression.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Expression 4 | from pydbml.parser.blueprints import ExpressionBlueprint 5 | 6 | 7 | class TestNote(TestCase): 8 | def test_build(self) -> None: 9 | bp = ExpressionBlueprint(text='amount*2') 10 | result = bp.build() 11 | self.assertIsInstance(result, Expression) 12 | self.assertEqual(result.text, bp.text) 13 | -------------------------------------------------------------------------------- /test/test_data/docs/index_definition.dbml: -------------------------------------------------------------------------------- 1 | Table bookings { 2 | id integer 3 | country varchar 4 | booking_date date 5 | created_at timestamp 6 | 7 | indexes { 8 | (id, country) [pk] // composite primary key 9 | created_at [name: 'created_at_index', note: 'Date'] 10 | booking_date 11 | (country, booking_date) [unique] 12 | booking_date [type: hash] 13 | (`id*2`) 14 | (`id*3`,`getdate()`) 15 | (`id*3`,id) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/note.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | from pydbml.classes import Note 4 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 5 | from pydbml.renderer.dbml.default.utils import quote_string 6 | 7 | 8 | @DefaultDBMLRenderer.renderer_for(Note) 9 | def render_note(model: Note) -> str: 10 | text = quote_string(model.text) 11 | 12 | text = indent(text, ' ') 13 | result = f'Note {{\n{text}\n}}' 14 | return result 15 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/__init__.py: -------------------------------------------------------------------------------- 1 | from .column import render_column 2 | from .enum import render_enum, render_enum_item 3 | from .expression import render_expression 4 | from .index import render_index 5 | from .note import render_note 6 | from .project import render_project 7 | from .reference import render_reference 8 | from .renderer import DefaultDBMLRenderer 9 | from .sticky_note import render_sticky_note 10 | from .table import render_table 11 | from .table_group import render_table_group 12 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/sticky_note.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | 4 | from pydbml.classes import StickyNote 5 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 6 | from pydbml.renderer.dbml.default.utils import quote_string 7 | 8 | 9 | @DefaultDBMLRenderer.renderer_for(StickyNote) 10 | def render_sticky_note(model: StickyNote) -> str: 11 | text = quote_string(model.text) 12 | 13 | text = indent(text, ' ') 14 | result = f'Note {model.name} {{\n{text}\n}}' 15 | return result 16 | -------------------------------------------------------------------------------- /test/test_data/docs/project_notes.dbml: -------------------------------------------------------------------------------- 1 | Project DBML { 2 | Note: ''' 3 | # DBML - Database Markup Language 4 | DBML (database markup language) is a simple, readable DSL language designed to define database structures. 5 | 6 | ## Benefits 7 | 8 | * It is simple, flexible and highly human-readable 9 | * It is database agnostic, focusing on the essential database structure definition without worrying about the detailed syntaxes of each database 10 | * Comes with a free, simple database visualiser at [dbdiagram.io](http://dbdiagram.io) 11 | ''' 12 | } 13 | -------------------------------------------------------------------------------- /pydbml/_classes/expression.py: -------------------------------------------------------------------------------- 1 | from .base import SQLObject, DBMLObject 2 | 3 | 4 | class Expression(SQLObject, DBMLObject): 5 | def __init__(self, text: str): 6 | self.text = text 7 | 8 | def __str__(self) -> str: 9 | ''' 10 | >>> print(Expression('sum(amount)')) 11 | sum(amount) 12 | ''' 13 | 14 | return self.text 15 | 16 | def __repr__(self) -> str: 17 | ''' 18 | >>> Expression('sum(amount)') 19 | Expression('sum(amount)') 20 | ''' 21 | 22 | return f'Expression({repr(self.text)})' 23 | -------------------------------------------------------------------------------- /pydbml/exceptions.py: -------------------------------------------------------------------------------- 1 | class TableNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class ColumnNotFoundError(Exception): 6 | pass 7 | 8 | 9 | class IndexNotFoundError(Exception): 10 | pass 11 | 12 | 13 | class AttributeMissingError(Exception): 14 | pass 15 | 16 | 17 | class DuplicateReferenceError(Exception): 18 | pass 19 | 20 | 21 | class UnknownDatabaseError(Exception): 22 | pass 23 | 24 | 25 | class DBMLError(Exception): 26 | pass 27 | 28 | 29 | class DatabaseValidationError(Exception): 30 | pass 31 | 32 | 33 | class ValidationError(Exception): 34 | pass 35 | -------------------------------------------------------------------------------- /test/test_classes/test_note.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Note 2 | 3 | 4 | def test_init_types(): 5 | n1 = Note('My note text') 6 | n2 = Note(3) 7 | n3 = Note([1, 2, 3]) 8 | n4 = Note(None) 9 | n5 = Note(n1) 10 | 11 | assert n1.text == 'My note text' 12 | assert n2.text == '3' 13 | assert n3.text == '[1, 2, 3]' 14 | assert n4.text == '' 15 | assert n5.text == 'My note text' 16 | 17 | 18 | def test_str(note1: Note) -> None: 19 | assert str(note1) == 'Simple note' 20 | 21 | 22 | def test_repr(note1: Note) -> None: 23 | assert repr(note1) == "Note('Simple note')" 24 | -------------------------------------------------------------------------------- /test/test_data/docs/table_group.dbml: -------------------------------------------------------------------------------- 1 | Table table1 { 2 | id integer 3 | status int 4 | } 5 | 6 | Table table2 { 7 | id integer 8 | status int 9 | } 10 | 11 | Table table3 { 12 | id integer 13 | status int 14 | } 15 | 16 | Table merchants { 17 | id integer 18 | status int 19 | } 20 | 21 | Table countries { 22 | id integer 23 | status int 24 | } 25 | 26 | 27 | TableGroup tablegroup_name { // tablegroup is case-insensitive. 28 | table1 29 | table2 30 | table3 31 | } 32 | 33 | //example 34 | TableGroup e_commerce1 { 35 | merchants 36 | countries 37 | } 38 | -------------------------------------------------------------------------------- /test/test_classes/test_index.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Column 2 | from pydbml.classes import Index 3 | from pydbml.classes import Note 4 | from pydbml.classes import Table 5 | 6 | 7 | def test_note_property(): 8 | note1 = Note('column note') 9 | t = Table('products') 10 | c = Column('id', 'integer') 11 | i = Index(subjects=[c]) 12 | i.note = note1 13 | assert i.note.parent is i 14 | 15 | 16 | def test_repr(index1: Index) -> None: 17 | assert repr(index1) == "" 18 | 19 | 20 | def test_str(index1: Index) -> None: 21 | assert str(index1) == 'Index(products[name])' 22 | -------------------------------------------------------------------------------- /pydbml/_classes/note.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from .base import SQLObject, DBMLObject 4 | 5 | 6 | class Note(SQLObject, DBMLObject): 7 | dont_compare_fields = ('parent',) 8 | 9 | def __init__(self, text: Any) -> None: 10 | self.text: str 11 | self.text = str(text) if text is not None else '' 12 | self.parent: Any = None 13 | 14 | def __str__(self): 15 | '''Note text''' 16 | return self.text 17 | 18 | def __bool__(self): 19 | return bool(self.text) 20 | 21 | def __repr__(self): 22 | '''Note('Note text')''' 23 | return f'Note({repr(self.text)})' 24 | -------------------------------------------------------------------------------- /pydbml/definitions/sticky_note.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _, end, _c 4 | from .generic import string_literal, name 5 | from ..parser.blueprints import StickyNoteBlueprint 6 | 7 | sticky_note = _c + pp.CaselessLiteral('note') + _ + (name('name') + _ - '{' + _ - string_literal('text') + _ - '}') + end 8 | 9 | 10 | def parse_sticky_note(s, loc, tok): 11 | ''' 12 | Note single_line_note { 13 | 'This is a single line note' 14 | } 15 | ''' 16 | init_dict = {'name': tok['name'], 'text': tok['text']} 17 | 18 | return StickyNoteBlueprint(**init_dict) 19 | 20 | 21 | sticky_note.set_parse_action(parse_sticky_note) 22 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_sticky_note.py: -------------------------------------------------------------------------------- 1 | from pydbml._classes.sticky_note import StickyNote 2 | from pydbml.renderer.dbml.default import render_sticky_note 3 | 4 | 5 | class TestRenderNote: 6 | @staticmethod 7 | def test_oneline() -> None: 8 | note = StickyNote(name='mynote', text="Note text") 9 | assert render_sticky_note(note) == "Note mynote {\n 'Note text'\n}" 10 | 11 | @staticmethod 12 | def test_multiline() -> None: 13 | note = StickyNote(name='mynote', text="Note text\nwith multiple lines") 14 | assert ( 15 | render_sticky_note(note) 16 | == "Note mynote {\n '''\n Note text\n with multiple lines'''\n}" 17 | ) 18 | -------------------------------------------------------------------------------- /test/test_data/editing.dbml: -------------------------------------------------------------------------------- 1 | Table "products" { 2 | "id" int [pk] 3 | "name" varchar 4 | "merchant_id" int [not null] 5 | "price" int 6 | "status" "product status" 7 | "created_at" datetime [default: `now()`] 8 | 9 | 10 | Indexes { 11 | (merchant_id, status) [name: "product_status"] 12 | id [type: hash, unique] 13 | } 14 | } 15 | 16 | Enum "product status" { 17 | "Out of Stock" 18 | "In Stock" 19 | } 20 | 21 | Ref:"merchants"."id" < "products"."merchant_id" 22 | 23 | 24 | Table "merchants" { 25 | "id" int [pk] 26 | "merchant_name" varchar 27 | "country_code" int 28 | "created_at" varchar 29 | "admin_id" int 30 | } 31 | 32 | TableGroup g1 { 33 | products 34 | merchants 35 | } 36 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/renderer.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List 2 | 3 | from pydbml.renderer.base import BaseRenderer 4 | from pydbml._classes.base import DBMLObject 5 | 6 | if TYPE_CHECKING: # pragma: no cover 7 | from pydbml.database import Database 8 | 9 | 10 | class DefaultDBMLRenderer(BaseRenderer): 11 | model_renderers = {} 12 | 13 | @classmethod 14 | def render_db(cls, db: 'Database') -> str: 15 | items: List[DBMLObject] = [db.project] if db.project else [] 16 | refs = (ref for ref in db.refs if not ref.inline) 17 | items.extend((*db.enums, *db.tables, *refs, *db.table_groups, *db.sticky_notes)) 18 | 19 | return '\n\n'.join(cls.render(i) for i in items) 20 | -------------------------------------------------------------------------------- /pydbml/classes/__init__.py: -------------------------------------------------------------------------------- 1 | from .._classes.column import Column 2 | from .._classes.enum import Enum 3 | from .._classes.enum import EnumItem 4 | from .._classes.expression import Expression 5 | from .._classes.index import Index 6 | from .._classes.note import Note 7 | from .._classes.project import Project 8 | from .._classes.reference import Reference 9 | from .._classes.sticky_note import StickyNote 10 | from .._classes.table import Table 11 | from .._classes.table_group import TableGroup 12 | 13 | __all__ = [ 14 | "Column", 15 | "Enum", 16 | "EnumItem", 17 | "Expression", 18 | "Index", 19 | "Note", 20 | "Project", 21 | "Reference", 22 | "StickyNote", 23 | "Table", 24 | "TableGroup", 25 | ] 26 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_utils.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Note 2 | from pydbml.renderer.dbml.default.utils import note_option_to_dbml, comment_to_dbml 3 | 4 | 5 | class TestNoteOptionsToDBML: 6 | @staticmethod 7 | def test_oneline() -> None: 8 | note = Note("One line note") 9 | expected = "note: 'One line note'" 10 | assert note_option_to_dbml(note) == expected 11 | 12 | @staticmethod 13 | def test_multiline() -> None: 14 | note = Note("Multiline\nnote") 15 | expected = "note: '''Multiline\nnote'''" 16 | assert note_option_to_dbml(note) == expected 17 | 18 | 19 | def test_comment_to_dbml() -> None: 20 | assert comment_to_dbml("Simple comment") == "// Simple comment\n" 21 | -------------------------------------------------------------------------------- /test/test_data/relationships_composite.dbml: -------------------------------------------------------------------------------- 1 | Table posts as po { 2 | id integer [primary key] 3 | user_id integer 4 | post text 5 | tag char 6 | } 7 | 8 | Table reviews as re { 9 | id integer [primary key] 10 | user_id integer 11 | review text 12 | post_id integer 13 | tag char 14 | } 15 | 16 | 17 | Table posts2 as po2 { 18 | id integer [primary key] 19 | user_id integer 20 | post text 21 | tag char 22 | } 23 | 24 | Table reviews2 as re2 { 25 | id integer [primary key] 26 | user_id integer 27 | review text 28 | post_id integer 29 | tag char 30 | } 31 | 32 | ref refname: posts.(id, tag) > re.(post_id,tag) 33 | 34 | Ref refname2 { 35 | po2.(id , tag) > reviews2.(post_id , tag) 36 | } 37 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_renderer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from pydbml.renderer.dbml.default import DefaultDBMLRenderer 4 | 5 | 6 | def test_render_db() -> None: 7 | db = Mock( 8 | project=Mock(), # #1 9 | refs=(Mock(inline=False), Mock(inline=False), Mock(inline=True)), # #2, #3 10 | tables=[Mock(), Mock(), Mock()], # #4, #5, #6 11 | enums=[Mock(), Mock()], # #7, #8 12 | table_groups=[Mock(), Mock()], # #9, #10 13 | sticky_notes=[Mock(), Mock()], # #11, #12 14 | ) 15 | 16 | with patch.object( 17 | DefaultDBMLRenderer, "render", Mock(return_value="") 18 | ) as render_mock: 19 | DefaultDBMLRenderer.render_db(db) 20 | assert render_mock.call_count == 12 21 | -------------------------------------------------------------------------------- /pydbml/_classes/sticky_note.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydbml._classes.base import DBMLObject 4 | 5 | 6 | class StickyNote(DBMLObject): 7 | dont_compare_fields = ('database',) 8 | 9 | def __init__(self, name: str, text: Any) -> None: 10 | self.name = name 11 | self.text = str(text) if text is not None else '' 12 | 13 | self.database = None 14 | 15 | def __str__(self): 16 | '''StickyNote('mynote', 'Note text')''' 17 | return self.__class__.__name__ + f'({repr(self.name)}, {repr(self.text)})' 18 | 19 | def __bool__(self): 20 | return bool(self.text) 21 | 22 | def __repr__(self): 23 | '''''' 24 | return f'<{self.__class__.__name__} {self.name!r}, {self.text!r}>' 25 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/renderer.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from pydbml.renderer.sql.default.utils import reorder_tables_for_sql 4 | from pydbml.renderer.base import BaseRenderer 5 | 6 | 7 | if TYPE_CHECKING: # pragma: no cover 8 | from pydbml.database import Database 9 | 10 | 11 | class DefaultSQLRenderer(BaseRenderer): 12 | model_renderers = {} 13 | 14 | @classmethod 15 | def render(cls, model) -> str: 16 | model.check_attributes_for_sql() 17 | return super().render(model) 18 | 19 | @classmethod 20 | def render_db(cls, db: 'Database') -> str: 21 | refs = (ref for ref in db.refs if not ref.inline) 22 | tables = reorder_tables_for_sql(db.tables, db.refs) 23 | components = (cls.render(i) for i in (*db.enums, *tables, *refs)) 24 | return '\n\n'.join(components) 25 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_note.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Note 2 | from pydbml.renderer.dbml.default.note import render_note 3 | from pydbml.renderer.dbml.default.utils import prepare_text_for_dbml 4 | 5 | 6 | def test_prepare_text_for_dbml() -> None: 7 | note = Note("""Three quotes: ''', one quote: '.""") 8 | assert prepare_text_for_dbml(note.text) == "Three quotes: \\''', one quote: \\'." 9 | 10 | 11 | class TestRenderNote: 12 | @staticmethod 13 | def test_oneline() -> None: 14 | note = Note("Note text") 15 | assert render_note(note) == "Note {\n 'Note text'\n}" 16 | 17 | @staticmethod 18 | def test_multiline() -> None: 19 | note = Note("Note text\nwith multiple lines") 20 | assert ( 21 | render_note(note) 22 | == "Note {\n '''\n Note text\n with multiple lines'''\n}" 23 | ) 24 | -------------------------------------------------------------------------------- /test/test_data/relationships_aliases.dbml: -------------------------------------------------------------------------------- 1 | Table posts as po { 2 | id integer [primary key] 3 | user_id integer 4 | } 5 | 6 | Table reviews as re { 7 | id integer [primary key] 8 | user_id integer 9 | } 10 | 11 | // or this 12 | Table users as us { 13 | id integer [ref: < po.user_id, ref: < re.user_id] // one to many 14 | } 15 | 16 | // The space after '<' is optional 17 | 18 | Table posts2 as po2 { 19 | id integer [primary key] 20 | user_id integer [ref: > us2.id] // many-to-one 21 | } 22 | 23 | Table reviews2 as re2 { 24 | id integer [primary key] 25 | user_id integer [ref: > users2.id] // many-to-one 26 | } 27 | 28 | // or this 29 | Table users2 as us2 { 30 | id integer 31 | } 32 | 33 | Table "alembic_version" { 34 | "version_num" "character varying(32)" [not null] 35 | 36 | Indexes { 37 | version_num [pk, name: "alembic_version_pk"] 38 | } 39 | } -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import TYPE_CHECKING 3 | 4 | from pydbml.tools import comment 5 | 6 | if TYPE_CHECKING: # pragma: no cover 7 | from pydbml.classes import Note 8 | 9 | 10 | def prepare_text_for_dbml(text: str) -> str: 11 | '''Escape single quotes''' 12 | pattern = re.compile(r"('''|')") 13 | return pattern.sub(r'\\\1', text) 14 | 15 | 16 | def quote_string(text: str) -> str: 17 | if '\n' in text: 18 | return f"'''\n{prepare_text_for_dbml(text)}'''" 19 | else: 20 | return f"'{prepare_text_for_dbml(text)}'" 21 | 22 | 23 | def note_option_to_dbml(note: 'Note') -> str: 24 | if '\n' in note.text: 25 | return f"note: '''{prepare_text_for_dbml(note.text)}'''" 26 | else: 27 | return f"note: '{prepare_text_for_dbml(note.text)}'" 28 | 29 | 30 | def comment_to_dbml(val: str) -> str: 31 | return comment(val, '//') 32 | -------------------------------------------------------------------------------- /test/test_data/integration1.dbml: -------------------------------------------------------------------------------- 1 | Project "my project" { 2 | author: 'me' 3 | reason: 'testing' 4 | } 5 | 6 | Enum "level" { 7 | "junior" 8 | "middle" 9 | "senior" 10 | } 11 | 12 | Table "Employees" as "emp" { 13 | "id" integer [pk, increment] 14 | "name" varchar [note: 'Full employee name'] 15 | "age" number [default: 0] 16 | "level" level 17 | "favorite_book_id" integer 18 | } 19 | 20 | Table "books" { 21 | "id" integer [pk, increment] 22 | "title" varchar 23 | "author" varchar 24 | "country_id" integer 25 | } 26 | 27 | Table "countries" { 28 | "id" integer [ref: < "books"."country_id", pk, increment] 29 | "name" varchar2 [unique] 30 | 31 | indexes { 32 | name [unique] 33 | `UPPER(name)` 34 | } 35 | } 36 | 37 | Ref { 38 | "Employees"."favorite_book_id" > "books"."id" 39 | } 40 | 41 | TableGroup "Unanimate" { 42 | "books" 43 | "countries" 44 | } -------------------------------------------------------------------------------- /test/test_definitions/test_generic.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyparsing import ParserElement 4 | 5 | from pydbml.definitions.generic import expression_literal, expression 6 | from pydbml.parser.blueprints import ExpressionBlueprint 7 | 8 | 9 | ParserElement.set_default_whitespace_chars(' \t\r') 10 | 11 | 12 | class TestExpressionLiteral(TestCase): 13 | def test_expression_literal(self) -> None: 14 | val = '`SUM(amount)`' 15 | res = expression_literal.parse_string(val) 16 | self.assertIsInstance(res[0], ExpressionBlueprint) 17 | self.assertEqual(res[0].text, 'SUM(amount)') 18 | 19 | class TestExpression(TestCase): 20 | def test_comma_separated_expression(self) -> None: 21 | val = 'MAX, 3, "MAX", \'MAX\'' 22 | expected = ['MAX', ',', '3', ',', '"MAX"', ',', "'MAX'"] 23 | res = expression.parse_string(val, parseAll=True) 24 | self.assertEqual(res.asList(), expected) 25 | -------------------------------------------------------------------------------- /test/test_data/integration1.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE "level" AS ENUM ( 2 | 'junior', 3 | 'middle', 4 | 'senior' 5 | ); 6 | 7 | CREATE TABLE "books" ( 8 | "id" integer PRIMARY KEY AUTOINCREMENT, 9 | "title" varchar, 10 | "author" varchar, 11 | "country_id" integer, 12 | CONSTRAINT "Country Reference" FOREIGN KEY ("country_id") REFERENCES "countries" ("id") 13 | ); 14 | 15 | CREATE TABLE "Employees" ( 16 | "id" integer PRIMARY KEY AUTOINCREMENT, 17 | "name" varchar, 18 | "age" number DEFAULT 0, 19 | "level" level, 20 | "favorite_book_id" integer 21 | ); 22 | 23 | COMMENT ON COLUMN "Employees"."name" IS 'Full employee name'; 24 | 25 | CREATE TABLE "countries" ( 26 | "id" integer PRIMARY KEY AUTOINCREMENT, 27 | "name" varchar2 UNIQUE 28 | ); 29 | 30 | CREATE UNIQUE INDEX ON "countries" ("name"); 31 | 32 | CREATE INDEX ON "countries" ((UPPER(name))); 33 | 34 | ALTER TABLE "Employees" ADD FOREIGN KEY ("favorite_book_id") REFERENCES "books" ("id"); -------------------------------------------------------------------------------- /test/test_classes/test_sticky_note.py: -------------------------------------------------------------------------------- 1 | from pydbml._classes.sticky_note import StickyNote 2 | 3 | 4 | def test_init_types(): 5 | n1 = StickyNote('mynote', 'My note text') 6 | n2 = StickyNote('mynote', 3) 7 | n3 = StickyNote('mynote', [1, 2, 3]) 8 | n4 = StickyNote('mynote', None) 9 | 10 | assert n1.text == 'My note text' 11 | assert n2.text == '3' 12 | assert n3.text == '[1, 2, 3]' 13 | assert n4.text == '' 14 | assert n1.name == n2.name == n3.name == n4.name == 'mynote' 15 | 16 | 17 | def test_str(sticky_note1: StickyNote) -> None: 18 | assert str(sticky_note1) == "StickyNote('mynote', 'Simple note')" 19 | 20 | 21 | def test_repr(sticky_note1: StickyNote) -> None: 22 | assert repr(sticky_note1) == "" 23 | 24 | 25 | def test_bool(sticky_note1: StickyNote) -> None: 26 | assert bool(sticky_note1) is True 27 | sticky_note1.text = '' 28 | assert bool(sticky_note1) is False 29 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/table_group.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | from pydbml.classes import TableGroup 4 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 5 | from pydbml.renderer.dbml.default.table import get_full_name_for_dbml 6 | from pydbml.renderer.dbml.default.utils import comment_to_dbml 7 | from pydbml.tools import doublequote_string 8 | 9 | 10 | @DefaultDBMLRenderer.renderer_for(TableGroup) 11 | def render_table_group(model: TableGroup) -> str: 12 | result = comment_to_dbml(model.comment) if model.comment else '' 13 | quoted_name = doublequote_string(model.name) 14 | result += f'TableGroup {quoted_name}' 15 | if model.color: 16 | result += f' [color: {model.color}]' 17 | result += ' {\n' 18 | for i in model.items: 19 | result += f' {get_full_name_for_dbml(i)}\n' 20 | if model.note: 21 | result += indent(model.note.dbml, ' ') + '\n' 22 | result += '}' 23 | return result 24 | -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 100% 19 | 100% 20 | 21 | 22 | -------------------------------------------------------------------------------- /pydbml/_classes/project.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | from typing import Optional 3 | from typing import Union 4 | 5 | from pydbml._classes.base import DBMLObject 6 | from pydbml._classes.note import Note 7 | 8 | 9 | class Project(DBMLObject): 10 | dont_compare_fields = ('database',) 11 | 12 | def __init__(self, 13 | name: str, 14 | items: Optional[Dict[str, str]] = None, 15 | note: Optional[Union[Note, str]] = None, 16 | comment: Optional[str] = None): 17 | self.database = None 18 | self.name = name 19 | self.items = items or {} 20 | self.note = Note(note) 21 | self.comment = comment 22 | 23 | def __repr__(self): 24 | """""" 25 | return f'' 26 | 27 | @property 28 | def note(self): 29 | return self._note 30 | 31 | @note.setter 32 | def note(self, val: Note) -> None: 33 | self._note = val 34 | val.parent = self 35 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_renderer.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | from pydbml.renderer.sql.default import DefaultSQLRenderer 4 | 5 | 6 | def test_render() -> None: 7 | model = Mock() 8 | result = DefaultSQLRenderer.render(model) 9 | assert model.check_attributes_for_sql.called 10 | assert result == "" 11 | 12 | 13 | def test_render_db() -> None: 14 | db = Mock( 15 | refs=(Mock(inline=False), Mock(inline=False), Mock(inline=True)), 16 | tables=[Mock(), Mock(), Mock()], 17 | enums=[Mock(), Mock()], 18 | ) 19 | 20 | with patch( 21 | "pydbml.renderer.sql.default.renderer.reorder_tables_for_sql", 22 | Mock(return_value=db.tables), 23 | ) as reorder_mock: 24 | with patch.object( 25 | DefaultSQLRenderer, "render", Mock(return_value="") 26 | ) as render_mock: 27 | result = DefaultSQLRenderer.render_db(db) 28 | assert reorder_mock.called 29 | assert render_mock.call_count == 7 30 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/enum.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | from pydbml.classes import Enum, EnumItem 4 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 5 | from pydbml.renderer.dbml.default.utils import comment_to_dbml, note_option_to_dbml 6 | from pydbml.renderer.sql.default.utils import get_full_name_for_sql 7 | 8 | 9 | @DefaultDBMLRenderer.renderer_for(Enum) 10 | def render_enum(model: Enum) -> str: 11 | result = comment_to_dbml(model.comment) if model.comment else '' 12 | result += f'Enum {get_full_name_for_sql(model)} {{\n' 13 | items_str = '\n'.join(DefaultDBMLRenderer.render(i) for i in model.items) 14 | result += indent(items_str, ' ') 15 | result += '\n}' 16 | return result 17 | 18 | 19 | @DefaultDBMLRenderer.renderer_for(EnumItem) 20 | def render_enum_item(model: EnumItem) -> str: 21 | result = comment_to_dbml(model.comment) if model.comment else '' 22 | result += f'"{model.name}"' 23 | if model.note: 24 | result += f' [{note_option_to_dbml(model.note)}]' 25 | return result 26 | -------------------------------------------------------------------------------- /test/test_classes/test_base.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml._classes.base import SQLObject 4 | from pydbml.exceptions import AttributeMissingError 5 | 6 | 7 | class TestDBMLObject(TestCase): 8 | def test_check_attributes_for_sql(self) -> None: 9 | o = SQLObject() 10 | o.a1 = None 11 | o.b1 = None 12 | o.c1 = None 13 | o.required_attributes = ('a1', 'b1') 14 | with self.assertRaises(AttributeMissingError): 15 | o.check_attributes_for_sql() 16 | o.a1 = 1 17 | with self.assertRaises(AttributeMissingError): 18 | o.check_attributes_for_sql() 19 | o.b1 = 'a2' 20 | o.check_attributes_for_sql() 21 | 22 | def test_comparison(self) -> None: 23 | o1 = SQLObject() 24 | o1.a1 = None 25 | o1.b1 = 'c' 26 | o1.c1 = 123 27 | o2 = SQLObject() 28 | o2.a1 = None 29 | o2.b1 = 'c' 30 | o2.c1 = 123 31 | self.assertTrue(o1 == o2) 32 | o1.a2 = True 33 | self.assertFalse(o1 == o2) 34 | self.assertFalse(o1 == 123) 35 | -------------------------------------------------------------------------------- /test/test_classes/test_table_group.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Table 4 | from pydbml.classes import TableGroup 5 | 6 | 7 | class TestTableGroup(TestCase): 8 | def test_getitem(self) -> None: 9 | merchants = Table('merchants') 10 | countries = Table('countries') 11 | customers = Table('customers') 12 | tg = TableGroup( 13 | 'mytg', 14 | [merchants, countries, customers], 15 | comment='My table group\nmultiline comment' 16 | ) 17 | self.assertIs(tg[1], countries) 18 | with self.assertRaises(IndexError): 19 | tg[22] 20 | 21 | def test_iter(self) -> None: 22 | merchants = Table('merchants') 23 | countries = Table('countries') 24 | customers = Table('customers') 25 | tg = TableGroup( 26 | 'mytg', 27 | [merchants, countries, customers], 28 | comment='My table group\nmultiline comment' 29 | ) 30 | for i1, i2 in zip(tg, [merchants, countries, customers]): 31 | self.assertIs(i1, i2) 32 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/project.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | from typing import Dict 3 | 4 | from pydbml.classes import Project 5 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 6 | from pydbml.renderer.dbml.default.utils import comment_to_dbml 7 | from pydbml.tools import doublequote_string 8 | 9 | 10 | def render_items(items: Dict[str, str]) -> str: 11 | items_str = '' 12 | for k, v in items.items(): 13 | if '\n' in v: 14 | items_str += f"{k}: '''{v}'''\n" 15 | else: 16 | items_str += f"{k}: '{v}'\n" 17 | return indent(items_str.rstrip('\n'), ' ') + '\n' 18 | 19 | 20 | @DefaultDBMLRenderer.renderer_for(Project) 21 | def render_project(model: Project) -> str: 22 | result = comment_to_dbml(model.comment) if model.comment else '' 23 | quoted_name = doublequote_string(model.name) 24 | result += f'Project {quoted_name} {{\n' 25 | result += render_items(model.items) 26 | if model.note: 27 | result += indent(DefaultDBMLRenderer.render(model.note), ' ') + '\n' 28 | result += '}' 29 | return result 30 | -------------------------------------------------------------------------------- /test/test_doctest.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | 3 | from pydbml import database 4 | from pydbml._classes import column 5 | from pydbml._classes import enum 6 | from pydbml._classes import expression 7 | from pydbml._classes import index 8 | from pydbml._classes import note 9 | from pydbml._classes import project 10 | from pydbml._classes import reference 11 | from pydbml._classes import table 12 | from pydbml._classes import table_group 13 | from pydbml.parser import parser 14 | 15 | 16 | def load_tests(loader, tests, ignore): 17 | tests.addTests(doctest.DocTestSuite(column)) 18 | tests.addTests(doctest.DocTestSuite(enum)) 19 | tests.addTests(doctest.DocTestSuite(expression)) 20 | tests.addTests(doctest.DocTestSuite(index)) 21 | tests.addTests(doctest.DocTestSuite(project)) 22 | tests.addTests(doctest.DocTestSuite(note)) 23 | tests.addTests(doctest.DocTestSuite(reference)) 24 | tests.addTests(doctest.DocTestSuite(database)) 25 | tests.addTests(doctest.DocTestSuite(table)) 26 | tests.addTests(doctest.DocTestSuite(table_group)) 27 | tests.addTests(doctest.DocTestSuite(parser)) 28 | return tests 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/enum.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | 3 | from pydbml._classes.enum import EnumItem 4 | from pydbml.classes import Enum 5 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 6 | from pydbml.renderer.sql.default.utils import comment_to_sql, get_full_name_for_sql 7 | 8 | 9 | @DefaultSQLRenderer.renderer_for(Enum) 10 | def render_enum(model: Enum) -> str: 11 | ''' 12 | Returns SQL for enum type: 13 | 14 | CREATE TYPE "job_status" AS ENUM ( 15 | 'created', 16 | 'running', 17 | 'done', 18 | 'failure', 19 | ); 20 | ''' 21 | 22 | result = comment_to_sql(model.comment) if model.comment else '' 23 | result += f'CREATE TYPE {get_full_name_for_sql(model)} AS ENUM (\n' 24 | enum_body = '\n'.join(f'{indent(DefaultSQLRenderer.render(i), " ")}' for i in model.items) 25 | result += enum_body.rstrip(',') 26 | result += '\n);' 27 | return result 28 | 29 | 30 | @DefaultSQLRenderer.renderer_for(EnumItem) 31 | def render_enum_item(model: EnumItem) -> str: 32 | result = comment_to_sql(model.comment) if model.comment else '' 33 | result += f"'{model.name}'," 34 | return result 35 | -------------------------------------------------------------------------------- /test/test_data/dbml_schema_def.dbml: -------------------------------------------------------------------------------- 1 | Table "ecommerce"."users" as EU { 2 | id int [pk] 3 | name varchar 4 | ejs job_status 5 | ejs2 public.job_status 6 | eg schemaB.gender 7 | eg2 gender 8 | } 9 | 10 | Table public.users { 11 | id int [pk] 12 | name varchar 13 | pjs job_status 14 | pjs2 public.job_status 15 | pg schemaB.gender 16 | pg2 gender 17 | } 18 | 19 | Table products { 20 | id int [pk] 21 | name varchar 22 | } 23 | 24 | Table schemaA.products as A { 25 | id int [pk] 26 | name varchar [ref: > EU.id] 27 | } 28 | 29 | Table schemaA.locations { 30 | id int [pk] 31 | name varchar [ref: > users.id ] 32 | } 33 | 34 | Ref: "public".users.id < EU.id 35 | 36 | Ref name_optional { 37 | users.name < ecommerce.users.id 38 | } 39 | 40 | TableGroup tablegroup_name { // tablegroup is case-insensitive. 41 | public.products 42 | users 43 | ecommerce.users 44 | A 45 | } 46 | 47 | enum job_status { 48 | created2 [note: 'abcdef'] 49 | running2 50 | done2 51 | failure2 52 | } 53 | 54 | enum schemaB.gender { 55 | man 56 | woman 57 | nonbinary 58 | } 59 | 60 | enum gender { 61 | man2 62 | woman2 63 | nonbinary2 64 | } 65 | -------------------------------------------------------------------------------- /test/test_renderer/test_base.py: -------------------------------------------------------------------------------- 1 | from pydbml.renderer.base import BaseRenderer 2 | 3 | 4 | class SampleRenderer(BaseRenderer): 5 | model_renderers = {} 6 | 7 | 8 | def test_renderer_for() -> None: 9 | @SampleRenderer.renderer_for(str) 10 | def render_str(model): 11 | return 'str' 12 | 13 | assert len(SampleRenderer.model_renderers) == 1 14 | assert str in SampleRenderer.model_renderers 15 | assert SampleRenderer.model_renderers[str] is render_str 16 | 17 | 18 | class TestRender: 19 | @staticmethod 20 | def test_render() -> None: 21 | @SampleRenderer.renderer_for(str) 22 | def render_str(model): 23 | return 'str' 24 | 25 | assert SampleRenderer.render('') == 'str' 26 | 27 | @staticmethod 28 | def test_render_not_supported() -> None: 29 | assert SampleRenderer.render(1) == '' 30 | 31 | @staticmethod 32 | def test_unsupported_renderer_override() -> None: 33 | def unsupported_renderer(model): 34 | return 'unsupported' 35 | 36 | class SampleRenderer2(BaseRenderer): 37 | model_renderers = {} 38 | _unsupported_renderer = unsupported_renderer 39 | 40 | assert SampleRenderer2.render(1) == 'unsupported' 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | SHORT_DESCRIPTION = 'Python parser and builder for DBML' 4 | 5 | try: 6 | with open('README.md', encoding='utf8') as readme: 7 | LONG_DESCRIPTION = readme.read() 8 | 9 | except FileNotFoundError: 10 | LONG_DESCRIPTION = SHORT_DESCRIPTION 11 | 12 | 13 | setup( 14 | name='pydbml', 15 | python_requires='>=3.8', 16 | description=SHORT_DESCRIPTION, 17 | long_description=LONG_DESCRIPTION, 18 | long_description_content_type='text/markdown', 19 | version='1.2.1', 20 | author='Daniil Minukhin', 21 | author_email='ddddsa@gmail.com', 22 | url='https://github.com/Vanderhoof/PyDBML', 23 | packages=find_packages(exclude=['test', 'test.*']), 24 | license='MIT', 25 | platforms='any', 26 | install_requires=['pyparsing>=3.0.0'], 27 | classifiers=[ 28 | "Development Status :: 5 - Production/Stable", 29 | "Environment :: Console", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Topic :: Documentation", 35 | "Topic :: Text Processing :: Markup", 36 | "Topic :: Utilities", 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /pydbml/definitions/common.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .generic import string_literal 4 | from pydbml.parser.blueprints import NoteBlueprint 5 | 6 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 7 | 8 | comment = ( 9 | pp.Suppress("//") + pp.SkipTo(pp.LineEnd()) 10 | | pp.Suppress('/*') + ... + pp.Suppress('*/') 11 | ) 12 | 13 | # optional comment or newline 14 | _ = ('\n' | comment)[...].suppress() 15 | 16 | # optional comment or newline, but comments are captured 17 | _c = (pp.Suppress('\n') | comment('comment_before*'))[...] 18 | 19 | # optional captured comment 20 | c = comment('comment')[0, 1] 21 | 22 | n = pp.LineEnd() 23 | 24 | end = comment[...].suppress() + n | pp.StringEnd() 25 | 26 | # obligatory newline 27 | # n = pp.Suppress('\n')[1, ...] 28 | 29 | note = pp.CaselessLiteral("note:") + _ - string_literal('text') 30 | note.set_parse_action(lambda s, loc, tok: NoteBlueprint(tok['text'])) 31 | 32 | note_object = pp.CaselessLiteral('note') + _ - '{' + _ - string_literal('text') + _ - '}' 33 | note_object.set_parse_action(lambda s, loc, tok: NoteBlueprint(tok['text'])) 34 | 35 | pk = pp.CaselessLiteral("pk") 36 | unique = pp.CaselessLiteral("unique") 37 | 38 | hex_char = pp.Word(pp.srange('[0-9a-fA-F]'), exact=1) 39 | hex_color = ("#" - (hex_char * 3 ^ hex_char * 6)).leaveWhitespace() 40 | -------------------------------------------------------------------------------- /pydbml/definitions/generic.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from pydbml.parser.blueprints import ExpressionBlueprint 4 | 5 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 6 | 7 | name = pp.Word(pp.alphanums + '_') | pp.QuotedString('"') 8 | 9 | # Literals 10 | 11 | string_literal = ( 12 | pp.QuotedString("'", escChar="\\") 13 | ^ pp.QuotedString('"', escChar="\\") 14 | ^ pp.QuotedString("'''", escChar="\\", multiline=True) 15 | ) 16 | expression_literal = pp.Combine( 17 | pp.Suppress('`') 18 | + pp.CharsNotIn('`')[...] 19 | + pp.Suppress('`') 20 | ).set_parse_action(lambda s, lok, tok: ExpressionBlueprint(tok[0])) 21 | 22 | boolean_literal = ( 23 | pp.CaselessLiteral('true') 24 | | pp.CaselessLiteral('false') 25 | | pp.CaselessLiteral('NULL') 26 | ) 27 | number_literal = ( 28 | pp.Word(pp.nums) 29 | ^ pp.Combine( 30 | pp.Word(pp.nums) + '.' + pp.Word(pp.nums) 31 | ) 32 | ) 33 | 34 | # Expression 35 | 36 | expr_chars = pp.Word(pp.alphanums + "\"'`,._+- \n\t") 37 | expr_chars_no_comma_space = pp.Word(pp.alphanums + "\"'`._+-") 38 | expression = pp.Forward() 39 | factor = ( 40 | pp.Word(pp.alphanums + '_')[0, 1] + '(' + expression + ')' 41 | | expr_chars_no_comma_space + (pp.Literal(",") | ");" | (pp.LineEnd() + ");")) 42 | | expr_chars 43 | ) 44 | expression << factor[...] 45 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_enum.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Enum, EnumItem, Note 2 | from pydbml.renderer.dbml.default.enum import ( 3 | render_enum_item, 4 | render_enum, 5 | ) 6 | 7 | 8 | class TestRenderEnumItem: 9 | @staticmethod 10 | def test_simple(enum_item1: EnumItem) -> None: 11 | assert render_enum_item(enum_item1) == '"en-US"' 12 | 13 | @staticmethod 14 | def test_comment(enum_item1: EnumItem) -> None: 15 | enum_item1.comment = "comment" 16 | expected = '// comment\n"en-US"' 17 | assert render_enum_item(enum_item1) == expected 18 | 19 | @staticmethod 20 | def test_note(enum_item1: EnumItem) -> None: 21 | enum_item1.note = Note("Enum item note") 22 | expected = "\"en-US\" [note: 'Enum item note']" 23 | assert render_enum_item(enum_item1) == expected 24 | 25 | 26 | class TestEnum: 27 | @staticmethod 28 | def test_simple(enum1: Enum) -> None: 29 | expected = 'Enum "product status" {\n "production"\n "development"\n}' 30 | assert render_enum(enum1) == expected 31 | 32 | @staticmethod 33 | def test_comment(enum1: Enum) -> None: 34 | enum1.comment = "comment" 35 | expected = '// comment\nEnum "product status" {\n "production"\n "development"\n}' 36 | assert render_enum(enum1) == expected 37 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_enum.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import EnumItem, Enum 2 | from pydbml.renderer.sql.default import render_enum, render_enum_item 3 | 4 | 5 | class TestRenderEnumItem: 6 | @staticmethod 7 | def test_simple(enum_item1: EnumItem): 8 | expected = "'en-US'," 9 | assert render_enum_item(enum_item1) == expected 10 | 11 | @staticmethod 12 | def test_comment(enum_item1: EnumItem): 13 | enum_item1.comment = "Test comment" 14 | expected = "-- Test comment\n'en-US'," 15 | assert render_enum_item(enum_item1) == expected 16 | 17 | 18 | class TestRenderEnum: 19 | @staticmethod 20 | def test_simple_enum(enum1: Enum) -> None: 21 | expected = ( 22 | 'CREATE TYPE "product status" AS ENUM (\n' 23 | " 'production',\n" 24 | " 'development'\n" 25 | ");" 26 | ) 27 | assert render_enum(enum1) == expected 28 | 29 | @staticmethod 30 | def test_comments(enum1: Enum) -> None: 31 | enum1.comment = "Enum comment" 32 | expected = ( 33 | "-- Enum comment\n" 34 | 'CREATE TYPE "product status" AS ENUM (\n' 35 | " 'production',\n" 36 | " 'development'\n" 37 | ");" 38 | ) 39 | assert render_enum(enum1) == expected 40 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_column.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Column 2 | from pydbml.renderer.sql.default import render_column 3 | 4 | 5 | class TestRenderColumn: 6 | @staticmethod 7 | def test_simple(simple_column: Column) -> None: 8 | expected = '"id" integer' 9 | 10 | assert render_column(simple_column), expected 11 | 12 | @staticmethod 13 | def test_complex(complex_column: Column) -> None: 14 | expected = ( 15 | "-- This is a counter column\n" 16 | '"counter" "product status" PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL DEFAULT ' 17 | "0" 18 | ) 19 | assert render_column(complex_column) == expected 20 | 21 | @staticmethod 22 | def test_string(string_column: Column) -> None: 23 | expected = ( 24 | "-- This is a defaulted string column\n" 25 | '"name" varchar(255) UNIQUE NOT NULL DEFAULT ' 26 | "'value''s'" 27 | ) 28 | assert render_column(string_column) == expected 29 | 30 | @staticmethod 31 | def test_string(boolean_column: Column) -> None: 32 | expected = ( 33 | "-- This is a defaulted boolean column\n" 34 | '"enabled" boolean NOT NULL DEFAULT ' 35 | "False" 36 | ) 37 | assert render_column(boolean_column) == expected 38 | -------------------------------------------------------------------------------- /test/test_blueprints/test_project.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Note 4 | from pydbml.classes import Project 5 | from pydbml.parser.blueprints import NoteBlueprint 6 | from pydbml.parser.blueprints import ProjectBlueprint 7 | 8 | 9 | class TestProjectBlueprint(TestCase): 10 | def test_build_minimal(self) -> None: 11 | bp = ProjectBlueprint( 12 | name='MyProject' 13 | ) 14 | result = bp.build() 15 | self.assertIsInstance(result, Project) 16 | self.assertEqual(result.name, bp.name) 17 | 18 | def test_build_full(self) -> None: 19 | bp = ProjectBlueprint( 20 | name='MyProject', 21 | items={ 22 | 'author': 'John Wick', 23 | 'nickname': 'Baba Yaga', 24 | 'reason': 'revenge' 25 | }, 26 | note=NoteBlueprint(text='note text'), 27 | comment='comment text' 28 | ) 29 | result = bp.build() 30 | self.assertIsInstance(result, Project) 31 | self.assertEqual(result.name, bp.name) 32 | self.assertEqual(result.items, bp.items) 33 | self.assertIsNot(result.items, bp.items) 34 | self.assertIsInstance(result.note, Note) 35 | self.assertEqual(result.note.text, bp.note.text) 36 | self.assertEqual(result.comment, bp.comment) 37 | -------------------------------------------------------------------------------- /pydbml/renderer/base.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Callable, Dict, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: # pragma: no cover 4 | from pydbml.database import Database 5 | 6 | 7 | def unsupported_renderer(model) -> str: 8 | return '' 9 | 10 | 11 | class BaseRenderer: 12 | _unsupported_renderer = unsupported_renderer 13 | 14 | @property 15 | def model_renderers(cls) -> Dict[Type, Callable]: 16 | """A class attribute dictionary to store the model renderers.""" 17 | raise NotImplementedError # pragma: no cover 18 | 19 | @classmethod 20 | def render(cls, model) -> str: 21 | """ 22 | Render the model to a string. If the model is not supported, fall back to 23 | `self._unsupported_renderer` that by default returns an empty string. 24 | """ 25 | 26 | return cls.model_renderers.get(type(model), cls._unsupported_renderer)(model) # type: ignore 27 | 28 | @classmethod 29 | def renderer_for(cls, model_cls: Type) -> Callable: 30 | """A decorator to register a renderer for a model class.""" 31 | def decorator(func) -> Callable: 32 | cls.model_renderers[model_cls] = func # type: ignore 33 | return func 34 | return decorator 35 | 36 | @classmethod 37 | def render_db(cls, db: 'Database') -> str: 38 | raise NotImplementedError # pragma: no cover 39 | -------------------------------------------------------------------------------- /test/test_blueprints/test_index.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Index 4 | from pydbml.classes import Note 5 | from pydbml.parser.blueprints import IndexBlueprint 6 | from pydbml.parser.blueprints import NoteBlueprint 7 | 8 | 9 | class TestIndex(TestCase): 10 | def test_build_minimal(self) -> None: 11 | bp = IndexBlueprint( 12 | subject_names=['a', 'b', 'c'] 13 | ) 14 | result = bp.build() 15 | self.assertIsInstance(result, Index) 16 | self.assertEqual(result.subject_names, []) 17 | 18 | def test_build_full(self) -> None: 19 | bp = IndexBlueprint( 20 | subject_names=['a', 'b', 'c'], 21 | name='MyIndex', 22 | unique=True, 23 | type='hash', 24 | pk=True, 25 | note=NoteBlueprint(text='Note text'), 26 | comment='Comment text' 27 | ) 28 | result = bp.build() 29 | self.assertIsInstance(result, Index) 30 | self.assertEqual(result.subject_names, []) 31 | self.assertEqual(result.name, bp.name) 32 | self.assertEqual(result.unique, bp.unique) 33 | self.assertEqual(result.type, bp.type) 34 | self.assertEqual(result.pk, bp.pk) 35 | self.assertIsInstance(result.note, Note) 36 | self.assertEqual(result.note.text, bp.note.text) 37 | self.assertEqual(result.comment, bp.comment) 38 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/utils.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Union 2 | 3 | from pydbml.classes import Enum, Reference, Table 4 | from pydbml.constants import MANY_TO_ONE, ONE_TO_MANY 5 | from pydbml.tools import comment 6 | 7 | 8 | def comment_to_sql(val: str) -> str: 9 | return comment(val, '--') 10 | 11 | 12 | def reorder_tables_for_sql(tables: List['Table'], refs: List['Reference']) -> List['Table']: 13 | """ 14 | Attempt to reorder the tables, so that they are defined in SQL before they are referenced by 15 | inline foreign keys. 16 | 17 | Won't aid the rare cases of cross-references and many-to-many relations. 18 | """ 19 | 20 | references: Dict[str, int] = {} 21 | for ref in refs: 22 | if ref.inline: 23 | if ref.type == MANY_TO_ONE and ref.table1 is not None: 24 | table_name = ref.table1.name 25 | elif ref.type == ONE_TO_MANY and ref.table2 is not None: 26 | table_name = ref.table2.name 27 | else: # pragma: no cover 28 | continue 29 | references[table_name] = references.get(table_name, 0) + 1 30 | return sorted(tables, key=lambda t: references.get(t.name, 0), reverse=True) 31 | 32 | 33 | def get_full_name_for_sql(model: Union[Table, Enum]) -> str: 34 | if model.schema == 'public': 35 | return f'"{model.name}"' 36 | else: 37 | return f'"{model.schema}"."{model.name}"' 38 | -------------------------------------------------------------------------------- /test/test_definitions/test_sticky_note.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyparsing import ParseSyntaxException 4 | 5 | from pydbml.definitions.sticky_note import sticky_note 6 | 7 | 8 | class TestSticky(TestCase): 9 | def test_single_quote(self) -> None: 10 | val = "note mynote {'test note'}" 11 | res = sticky_note.parse_string(val, parseAll=True) 12 | self.assertEqual(res[0].name, 'mynote') 13 | self.assertEqual(res[0].text, 'test note') 14 | 15 | def test_double_quote(self) -> None: 16 | val = 'note \n\nmynote\n\n {\n\n"test note"\n\n}' 17 | res = sticky_note.parse_string(val, parseAll=True) 18 | self.assertEqual(res[0].name, 'mynote') 19 | self.assertEqual(res[0].text, 'test note') 20 | 21 | def test_multiline(self) -> None: 22 | val = "note\nmynote\n{ '''line1\nline2\nline3'''}" 23 | res = sticky_note.parse_string(val, parseAll=True) 24 | self.assertEqual(res[0].name, 'mynote') 25 | self.assertEqual(res[0].text, 'line1\nline2\nline3') 26 | 27 | def test_unclosed_quote(self) -> None: 28 | val = 'note mynote{ "test note}' 29 | with self.assertRaises(ParseSyntaxException): 30 | sticky_note.parse_string(val, parseAll=True) 31 | 32 | def test_not_allowed_multiline(self) -> None: 33 | val = "note mynote { 'line1\nline2\nline3' }" 34 | with self.assertRaises(ParseSyntaxException): 35 | sticky_note.parse_string(val, parseAll=True) 36 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_table_group.py: -------------------------------------------------------------------------------- 1 | from pydbml._classes.note import Note 2 | from pydbml._classes.table_group import TableGroup 3 | from pydbml.classes import Table 4 | from pydbml.renderer.dbml.default import render_table_group 5 | 6 | 7 | class TestTableGroup: 8 | @staticmethod 9 | def test_simple(table1: Table, table2: Table, table3: Table) -> None: 10 | tg = TableGroup( 11 | name="mygroup", 12 | items=[table1, table2, table3], 13 | ) 14 | expected = ( 15 | 'TableGroup "mygroup" {\n' 16 | ' "products"\n' 17 | ' "products"\n' 18 | ' "orders"\n' 19 | '}' 20 | ) 21 | assert render_table_group(tg) == expected 22 | 23 | @staticmethod 24 | def test_full(table1: Table, table2: Table, table3: Table) -> None: 25 | tg = TableGroup( 26 | name="mygroup", 27 | items=[table1, table2, table3], 28 | comment="My comment", 29 | note=Note('Note line1\nNote line2'), 30 | color='#FFF' 31 | ) 32 | expected = ( 33 | '// My comment\n' 34 | 'TableGroup "mygroup" [color: #FFF] {\n' 35 | ' "products"\n' 36 | ' "products"\n' 37 | ' "orders"\n' 38 | ' Note {\n' 39 | " '''\n" 40 | ' Note line1\n' 41 | " Note line2'''\n" 42 | ' }\n' 43 | '}' 44 | ) 45 | assert render_table_group(tg) == expected 46 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/index.py: -------------------------------------------------------------------------------- 1 | from typing import List, Any 2 | 3 | from pydbml.classes import Index, Expression, Column 4 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 5 | from pydbml.renderer.dbml.default.utils import comment_to_dbml, note_option_to_dbml 6 | 7 | 8 | def render_subjects(source_subjects: List[Any]) -> str: 9 | subjects = [] 10 | 11 | for subj in source_subjects: 12 | if isinstance(subj, Column): 13 | subjects.append(subj.name) 14 | elif isinstance(subj, Expression): 15 | subjects.append(DefaultDBMLRenderer.render(subj)) 16 | else: 17 | subjects.append(subj) 18 | 19 | if len(subjects) > 1: 20 | return f'({", ".join(subj for subj in subjects)})' 21 | else: 22 | return subjects[0] 23 | 24 | 25 | def render_options(model: Index) -> str: 26 | options = [] 27 | if model.name: 28 | options.append(f"name: '{model.name}'") 29 | if model.pk: 30 | options.append('pk') 31 | if model.unique: 32 | options.append('unique') 33 | if model.type: 34 | options.append(f'type: {model.type}') 35 | if model.note: 36 | options.append(note_option_to_dbml(model.note)) 37 | 38 | if options: 39 | return f' [{", ".join(options)}]' 40 | return '' 41 | 42 | 43 | @DefaultDBMLRenderer.renderer_for(Index) 44 | def render_index(model: Index) -> str: 45 | return ( 46 | (comment_to_dbml(model.comment) if model.comment else '') 47 | + render_subjects(model.subjects) 48 | + render_options(model) 49 | ) 50 | -------------------------------------------------------------------------------- /pydbml/definitions/project.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _ 4 | from .common import _c 5 | from .common import n 6 | from .common import note 7 | from .common import note_object 8 | from .generic import name 9 | from .generic import string_literal 10 | from pydbml.parser.blueprints import NoteBlueprint 11 | from pydbml.parser.blueprints import ProjectBlueprint 12 | 13 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 14 | 15 | project_field = pp.Group(name + _ + pp.Suppress(':') + _ - string_literal) 16 | 17 | project_element = _ + (note | note_object | project_field) + _ 18 | 19 | project_body = project_element[...] 20 | 21 | project = _c + ( 22 | pp.CaselessLiteral('project') + _ 23 | - name('name') + _ 24 | + '{' + _ 25 | - project_body('items') + _ 26 | - '}' 27 | ) + (n | pp.StringEnd()) 28 | 29 | 30 | def parse_project(s, loc, tok): 31 | ''' 32 | Project project_name { 33 | database_type: 'PostgreSQL' 34 | Note: 'Description of the project' 35 | } 36 | ''' 37 | init_dict = {'name': tok['name']} 38 | items = {} 39 | for item in tok.get('items', []): 40 | if isinstance(item, NoteBlueprint): 41 | init_dict['note'] = item 42 | else: 43 | k, v = item 44 | items[k] = v 45 | if items: 46 | init_dict['items'] = items 47 | if 'comment_before' in tok: 48 | comment = '\n'.join(c[0] for c in tok['comment_before']) 49 | init_dict['comment'] = comment 50 | return ProjectBlueprint(**init_dict) 51 | 52 | 53 | project.set_parse_action(parse_project) 54 | -------------------------------------------------------------------------------- /test/test_classes/test_enum.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Enum 2 | from pydbml.classes import EnumItem 3 | from pydbml.classes import Note 4 | from unittest import TestCase 5 | 6 | 7 | class TestEnumItem(TestCase): 8 | def test_note_property(self): 9 | note1 = Note('enum item note') 10 | ei = EnumItem('en-US', note='preferred', comment='EnumItem comment') 11 | ei.note = note1 12 | self.assertIs(ei.note.parent, ei) 13 | 14 | 15 | class TestEnum(TestCase): 16 | def test_getitem(self) -> None: 17 | ei = EnumItem('created') 18 | items = [ 19 | EnumItem('running'), 20 | ei, 21 | EnumItem('donef'), 22 | EnumItem('failure'), 23 | ] 24 | e = Enum('job_status', items) 25 | self.assertIs(e[1], ei) 26 | with self.assertRaises(IndexError): 27 | e[22] 28 | with self.assertRaises(TypeError): 29 | e['abc'] 30 | 31 | def test_iter(self) -> None: 32 | ei1 = EnumItem('created') 33 | ei2 = EnumItem('running') 34 | ei3 = EnumItem('donef') 35 | ei4 = EnumItem('failure') 36 | items = [ 37 | ei1, 38 | ei2, 39 | ei3, 40 | ei4, 41 | ] 42 | e = Enum('job_status', items) 43 | 44 | for i1, i2 in zip(e, [ei1, ei2, ei3, ei4]): 45 | self.assertIs(i1, i2) 46 | 47 | 48 | def test_repr(enum_item1: EnumItem) -> None: 49 | assert repr(enum_item1) == "" 50 | 51 | 52 | def test_str() -> None: 53 | ei = EnumItem('en-US') 54 | assert str(ei) == 'en-US' 55 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_note.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from pydbml.classes import Note, Table, Index 4 | from pydbml.renderer.sql.default.note import prepare_text_for_sql, generate_comment_on, render_note 5 | 6 | 7 | def test_prepare_text_for_sql() -> None: 8 | text = dedent( 9 | """\ 10 | First line break is preserved 11 | second line break \\ 12 | is 'ignored' """ 13 | ) 14 | expected = 'First line break is preserved\nsecond line break is "ignored" ' 15 | assert prepare_text_for_sql(Note(text)) == expected 16 | 17 | 18 | def test_generate_comment_on(note1: Note) -> None: 19 | expected = "COMMENT ON TABLE \"table1\" IS 'Simple note';" 20 | 21 | assert generate_comment_on(note1, "Table", "table1") == expected 22 | 23 | 24 | class TestRenderNote: 25 | @staticmethod 26 | def test_table_note_with_text(note1: Note, table1: Table) -> None: 27 | table1.note = note1 28 | expected = "COMMENT ON TABLE \"products\" IS 'Simple note';" 29 | assert render_note(note1) == expected 30 | 31 | @staticmethod 32 | def test_table_note_without_text(note1: Note, table1: Table) -> None: 33 | table1.note = note1 34 | note1.text = "" 35 | assert render_note(note1) == "" 36 | 37 | @staticmethod 38 | def test_index_note(index1: Index, multiline_note: Note) -> None: 39 | index1.note = multiline_note 40 | expected = dedent( 41 | """\ 42 | -- This is a multiline note. 43 | -- It has multiple lines.""" 44 | ) 45 | assert render_note(multiline_note) == expected 46 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/note.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from pydbml.classes import Note, Table, Column 4 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 5 | 6 | 7 | def prepare_text_for_sql(model: Note) -> str: 8 | ''' 9 | - Process special escape sequence: slash before line break, which means no line break 10 | https://www.dbml.org/docs/#multi-line-string 11 | - replace all single quotes with double quotes 12 | ''' 13 | 14 | pattern = re.compile(r'\\\n') 15 | result = pattern.sub('', model.text) 16 | 17 | result = result.replace("'", '"') 18 | return result 19 | 20 | 21 | def generate_comment_on(model: Note, entity: str, name: str) -> str: 22 | """Generate a COMMENT ON clause out from this note.""" 23 | quoted_text = f"'{prepare_text_for_sql(model)}'" 24 | note_sql = f'COMMENT ON {entity.upper()} "{name}" IS {quoted_text};' 25 | return note_sql 26 | 27 | 28 | @DefaultSQLRenderer.renderer_for(Note) 29 | def render_note(model: Note) -> str: 30 | """ 31 | For Tables and Columns Note is converted into COMMENT ON clause. All other entities don't 32 | have notes generated in their SQL code, but as a fallback their notes are rendered as SQL 33 | comments when sql property is called directly. 34 | """ 35 | 36 | if model.text: 37 | if isinstance(model.parent, (Table, Column)): 38 | return generate_comment_on(model, model.parent.__class__.__name__, model.parent.name) 39 | else: 40 | text = prepare_text_for_sql(model) 41 | return '\n'.join(f'-- {line}' for line in text.split('\n')) 42 | else: 43 | return '' 44 | -------------------------------------------------------------------------------- /pydbml/_classes/table_group.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Optional 3 | 4 | from pydbml._classes.base import DBMLObject 5 | from pydbml._classes.note import Note 6 | from pydbml._classes.table import Table 7 | 8 | 9 | class TableGroup(DBMLObject): 10 | ''' 11 | TableGroup `items` parameter initially holds just the names of the tables, 12 | but after parsing the whole document, PyDBMLParseResults class replaces 13 | them with references to actual tables. 14 | ''' 15 | dont_compare_fields = ('database',) 16 | 17 | def __init__(self, 18 | name: str, 19 | items: List[Table], 20 | comment: Optional[str] = None, 21 | note: Optional[Note] = None, 22 | color: Optional[str] = None): 23 | self.database = None 24 | self.name = name 25 | self.items = items 26 | self.comment = comment 27 | self.note = note 28 | self.color = color 29 | 30 | def __repr__(self): 31 | """ 32 | >>> tg = TableGroup('mygroup', ['t1', 't2']) 33 | >>> tg 34 | 35 | >>> t1 = Table('t1') 36 | >>> t2 = Table('t2') 37 | >>> tg.items = [t1, t2] 38 | >>> tg 39 | 40 | """ 41 | 42 | items = [i if isinstance(i, str) else i.name for i in self.items] 43 | return f'' 44 | 45 | def __getitem__(self, key: int) -> Table: 46 | return self.items[key] 47 | 48 | def __iter__(self): 49 | return iter(self.items) 50 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_project.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Project, Note 2 | from pydbml.renderer.dbml.default.project import render_items, render_project 3 | 4 | 5 | class TestRenderItems: 6 | @staticmethod 7 | def test_oneline(): 8 | project = Project(name="test", items={"key1": "value1"}) 9 | assert render_items(project.items) == " key1: 'value1'\n" 10 | 11 | @staticmethod 12 | def test_multiline(): 13 | project = Project(name="test", items={"key1": "value1\nvalue2"}) 14 | assert render_items(project.items) == " key1: '''value1\n value2'''\n" 15 | 16 | @staticmethod 17 | def test_multiple(): 18 | project = Project( 19 | name="test", items={"key1": "value1", "key2": "value2\nnewline"} 20 | ) 21 | assert ( 22 | render_items(project.items) 23 | == " key1: 'value1'\n key2: '''value2\n newline'''\n" 24 | ) 25 | 26 | 27 | class TestRenderProject: 28 | @staticmethod 29 | def test_no_note() -> None: 30 | project = Project(name="test", items={"key1": "value1"}) 31 | expected = "Project \"test\" {\n key1: 'value1'\n}" 32 | assert render_project(project) == expected 33 | 34 | @staticmethod 35 | def test_note() -> None: 36 | project = Project(name="test", items={"key1": "value1"}) 37 | project.note = Note("Note text") 38 | expected = ( 39 | 'Project "test" {\n' 40 | " key1: 'value1'\n" 41 | " Note {\n" 42 | " 'Note text'\n" 43 | " }\n" 44 | "}" 45 | ) 46 | assert render_project(project) == expected 47 | -------------------------------------------------------------------------------- /pydbml/tools.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: # pragma: no cover 5 | pass 6 | 7 | 8 | def comment(val: str, comb: str) -> str: 9 | return '\n'.join(f'{comb} {cl}' for cl in val.split('\n')) + '\n' 10 | 11 | 12 | def indent(val: str, spaces=4) -> str: 13 | if val == '': 14 | return val 15 | return ' ' * spaces + val.replace('\n', '\n' + ' ' * spaces) 16 | 17 | 18 | def remove_bom(source: str) -> str: 19 | if source and source[0] == '\ufeff': 20 | source = source[1:] 21 | return source 22 | 23 | 24 | def strip_empty_lines(source: str) -> str: 25 | """Remove empty lines or lines with just spaces from beginning and end.""" 26 | pattern = re.compile(r'^([ \t]*\n)*(?P[\s\S]+?)(\n[ \t]*)*$') 27 | return pattern.sub(r'\g', source) 28 | 29 | 30 | def doublequote_string(source: str) -> str: 31 | """Safely wrap a single-line string in double quotes""" 32 | if '\n' in source: 33 | raise ValueError(f'Multiline strings are not allowed: {source!r}') 34 | result = source.strip('"').replace('"', '\\"') 35 | return f'"{result}"' 36 | 37 | 38 | def remove_indentation(source: str) -> str: 39 | if not source: 40 | return source 41 | 42 | pattern = re.compile(r'^\s*') 43 | 44 | lines = source.split('\n') 45 | spaces = [] 46 | for line in lines: 47 | if line and not line.isspace(): 48 | indent_match = pattern.search(line) 49 | if indent_match is not None: # this is just for you mypy 50 | spaces.append(len(indent_match[0])) 51 | 52 | indent = min(spaces) 53 | lines = [l[indent:] for l in lines] 54 | return '\n'.join(lines) 55 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/column.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from pydbml.classes import Column, Enum, Expression 4 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 5 | from .utils import comment_to_sql 6 | from .enum import get_full_name_for_sql as get_full_name_for_sql_enum 7 | 8 | def default_to_str(val: Union[Expression, str, int, float, bool]) -> str: 9 | if isinstance(val, Expression): 10 | return DefaultSQLRenderer.render(val) 11 | elif isinstance(val, str): 12 | val = val.replace("'", "''") 13 | return f"'{val}'" 14 | else: 15 | return str(val) 16 | 17 | @DefaultSQLRenderer.renderer_for(Column) 18 | def render_column(model: Column) -> str: 19 | ''' 20 | Returns inline SQL of the column, which should be a part of table definition: 21 | 22 | "id" integer PRIMARY KEY AUTOINCREMENT 23 | ''' 24 | 25 | components = [f'"{model.name}"'] 26 | if isinstance(model.type, Enum): 27 | components.append(get_full_name_for_sql_enum(model.type)) 28 | else: 29 | components.append(str(model.type)) 30 | 31 | table_has_composite_pk = model.table._has_composite_pk() if model.table else False 32 | if model.pk and not table_has_composite_pk: # composite PKs are rendered in table sql 33 | components.append('PRIMARY KEY') 34 | if model.autoinc: 35 | components.append('AUTOINCREMENT') 36 | if model.unique: 37 | components.append('UNIQUE') 38 | if model.not_null: 39 | components.append('NOT NULL') 40 | if model.default is not None: 41 | components.append(f'DEFAULT {default_to_str(model.default)}') 42 | 43 | result = comment_to_sql(model.comment) if model.comment else '' 44 | result += ' '.join(components) 45 | return result 46 | -------------------------------------------------------------------------------- /test/test_data/general.dbml: -------------------------------------------------------------------------------- 1 | Enum "orders_status" { 2 | "created" 3 | "running" 4 | "done" 5 | "failure" 6 | } 7 | 8 | Enum "product status" { 9 | "Out of Stock" 10 | "In Stock" 11 | } 12 | 13 | Table "orders" [headercolor: #fff] { 14 | "id" int [pk, increment] 15 | "user_id" int [unique, not null] 16 | "status" orders_status 17 | "created_at" varchar 18 | } 19 | 20 | Table "order_items" { 21 | "order_id" int 22 | "product_id" int 23 | "quantity" int [default: 1] 24 | } 25 | 26 | Table "products" { 27 | "id" int [pk] 28 | "name" varchar 29 | "merchant_id" int [not null] 30 | "price" int 31 | "status" "product status" 32 | "created_at" datetime [default: `now()`] 33 | 34 | 35 | Indexes { 36 | (merchant_id, status) [name: "product_status"] 37 | id [type: hash, unique] 38 | } 39 | } 40 | 41 | Table "users" { 42 | "id" int [pk] 43 | "full_name" varchar 44 | "email" varchar [unique] 45 | "gender" varchar 46 | "date_of_birth" varchar 47 | "created_at" varchar 48 | "country_code" int 49 | } 50 | 51 | TableGroup g1 { 52 | users 53 | merchants 54 | } 55 | 56 | TableGroup g2 { 57 | countries 58 | orders 59 | } 60 | 61 | Table "merchants" { 62 | "id" int [pk] 63 | "merchant_name" varchar 64 | "country_code" int 65 | "created_at" varchar 66 | "admin_id" int 67 | } 68 | 69 | Table "countries" { 70 | "code" int [pk] 71 | "name" varchar 72 | "continent_name" varchar 73 | } 74 | 75 | Ref:"orders"."id" < "order_items"."order_id" 76 | 77 | Ref:"products"."id" < "order_items"."product_id" 78 | 79 | Ref:"users"."country_code" > "countries"."code" 80 | 81 | Ref:"countries"."code" < "merchants"."country_code" 82 | 83 | Ref:"products"."merchant_id" - "merchants"."id" 84 | 85 | Ref:"users"."id" < "merchants"."admin_id" 86 | // final comment 87 | -------------------------------------------------------------------------------- /test/test_data/notes.dbml: -------------------------------------------------------------------------------- 1 | Project "my project" { 2 | author: 'me' 3 | reason: 'testing' 4 | Note: ''' 5 | # DBML - Database Markup Language 6 | DBML (database markup language) is a simple, readable DSL language designed to define database structures. 7 | 8 | ## Benefits 9 | 10 | * It is simple, flexible and highly human-readable 11 | * It is database agnostic, focusing on the essential database structure definition without worrying about the detailed syntaxes of each database 12 | * Comes with a free, simple database visualiser at [dbdiagram.io](http://dbdiagram.io) 13 | ''' 14 | } 15 | 16 | Enum "level" { 17 | "junior" [note: 'enum item note'] 18 | "middle" 19 | "senior" 20 | } 21 | 22 | Table "orders" [headercolor: #fff] { 23 | "id" int [pk, increment] 24 | "user_id" int [unique, not null] 25 | "status" orders_status [note: "test note"] 26 | "created_at" varchar 27 | Note: 'Simple one line note' 28 | } 29 | 30 | Table "order_items" { 31 | "order_id" int 32 | "product_id" int 33 | "quantity" int [default: 1] 34 | Note: 'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Doloremque exercitationem facere eos, quod error consectetur.' 35 | indexes { 36 | order_id [unique, Note: 'Index note'] 37 | `ROUND(quantity)` 38 | } 39 | } 40 | 41 | Table "products" { 42 | "id" int [pk] 43 | "name" varchar 44 | "merchant_id" int [not null] 45 | "price" int 46 | "status" "product status" 47 | "created_at" datetime [default: `now()`] 48 | Note { 49 | '''Indented note which is actually a Markdown formated string: 50 | 51 | - List item 1 52 | - Another list item 53 | 54 | ```[python 55 | def test(): 56 | print('Hello world!') 57 | return 1 58 | ```''' 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pydbml/definitions/table_group.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from pydbml.parser.blueprints import TableGroupBlueprint, NoteBlueprint 4 | from .common import _, note, note_object, hex_color 5 | from .common import _c 6 | from .common import end 7 | from .generic import name 8 | 9 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 10 | 11 | table_name = pp.Combine(name + '.' + name) | name 12 | note_element = note | note_object 13 | 14 | tg_element = _ + (note_element('note') | table_name.set_results_name('items', list_all_matches=True)) + _ 15 | 16 | tg_body = tg_element[...] 17 | 18 | 19 | tg_color = ( 20 | pp.CaselessLiteral('color:').suppress() + _ 21 | - pp.Combine(hex_color)('color') 22 | ) 23 | tg_setting = _ + (note('note') | tg_color) + _ 24 | 25 | tg_settings = '[' + tg_setting + (',' + tg_setting)[...] + ']' 26 | 27 | table_group = _c + ( 28 | pp.CaselessLiteral('TableGroup') 29 | - name('name') + _ 30 | + tg_settings[0, 1] + _ 31 | - '{' + _ 32 | - tg_body + _ 33 | - '}' 34 | ) + end 35 | 36 | 37 | def parse_table_group(s, loc, tok): 38 | ''' 39 | TableGroup tablegroup_name { 40 | table1 41 | table2 42 | table3 43 | } 44 | ''' 45 | init_dict = { 46 | 'name': tok['name'], 47 | 'items': list(tok.get('items', [])) 48 | } 49 | if 'comment_before' in tok: 50 | comment = '\n'.join(c[0] for c in tok['comment_before']) 51 | init_dict['comment'] = comment 52 | if 'note' in tok: 53 | note = tok['note'] 54 | init_dict['note'] = note if isinstance(note, NoteBlueprint) else note[0] 55 | if 'color' in tok: 56 | init_dict['color'] = tok['color'] 57 | return TableGroupBlueprint(**init_dict) 58 | 59 | 60 | table_group.set_parse_action(parse_table_group) 61 | -------------------------------------------------------------------------------- /test/test_blueprints/test_enum.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Enum 4 | from pydbml.classes import EnumItem 5 | from pydbml.classes import Note 6 | from pydbml.parser.blueprints import EnumBlueprint 7 | from pydbml.parser.blueprints import EnumItemBlueprint 8 | from pydbml.parser.blueprints import NoteBlueprint 9 | 10 | 11 | class TestEnumItemBlueprint(TestCase): 12 | def test_build_minimal(self) -> None: 13 | bp = EnumItemBlueprint( 14 | name='Red' 15 | ) 16 | result = bp.build() 17 | self.assertIsInstance(result, EnumItem) 18 | self.assertEqual(result.name, bp.name) 19 | 20 | def test_build_full(self) -> None: 21 | bp = EnumItemBlueprint( 22 | name='Red', 23 | note=NoteBlueprint(text='Note text'), 24 | comment='Comment text' 25 | ) 26 | result = bp.build() 27 | self.assertIsInstance(result, EnumItem) 28 | self.assertEqual(result.name, bp.name) 29 | self.assertIsInstance(result.note, Note) 30 | self.assertEqual(result.note.text, bp.note.text) 31 | self.assertEqual(result.comment, bp.comment) 32 | 33 | 34 | class TestEnumBlueprint(TestCase): 35 | def test_build(self) -> None: 36 | bp = EnumBlueprint( 37 | name='Colors', 38 | items=[ 39 | EnumItemBlueprint(name='Red'), 40 | EnumItemBlueprint(name='Green'), 41 | EnumItemBlueprint(name='Blue') 42 | ], 43 | comment='Comment text' 44 | ) 45 | result = bp.build() 46 | self.assertIsInstance(result, Enum) 47 | self.assertEqual(result.name, bp.name) 48 | self.assertEqual(result.comment, bp.comment) 49 | for ei in result.items: 50 | self.assertIsInstance(ei, EnumItem) 51 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/table.py: -------------------------------------------------------------------------------- 1 | import re 2 | from textwrap import indent 3 | 4 | from pydbml.classes import Table 5 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 6 | from pydbml.renderer.dbml.default.utils import comment_to_dbml, quote_string 7 | 8 | 9 | def get_full_name_for_dbml(model) -> str: 10 | if model.schema == 'public': 11 | return f'"{model.name}"' 12 | else: 13 | return f'"{model.schema}"."{model.name}"' 14 | 15 | 16 | def render_header(model: Table) -> str: 17 | name = get_full_name_for_dbml(model) 18 | 19 | result = f'Table {name} ' 20 | if model.alias: 21 | result += f'as "{model.alias}" ' 22 | if model.header_color: 23 | result += f'[headercolor: {model.header_color}] ' 24 | return result 25 | 26 | 27 | def render_indexes(model: Table) -> str: 28 | if model.indexes: 29 | result = '\n indexes {\n' 30 | indexes_str = '\n'.join(DefaultDBMLRenderer.render(i) for i in model.indexes) 31 | result += indent(indexes_str, ' ') + '\n' 32 | result += ' }\n' 33 | return result 34 | return '' 35 | 36 | 37 | @DefaultDBMLRenderer.renderer_for(Table) 38 | def render_table(model: Table) -> str: 39 | result = comment_to_dbml(model.comment) if model.comment else '' 40 | result += render_header(model) 41 | 42 | result += '{\n' 43 | columns_str = '\n'.join(DefaultDBMLRenderer.render(c) for c in model.columns) 44 | result += indent(columns_str, ' ') + '\n' 45 | 46 | if model.properties: 47 | if model.database and model.database.allow_properties: 48 | properties_str = '\n' + '\n'.join(f'{key}: {quote_string(value)}' for key, value in model.properties.items()) + '\n' 49 | properties_str = indent(properties_str, ' ') 50 | result += properties_str 51 | 52 | if model.note: 53 | result += indent(model.note.dbml, ' ') + '\n' 54 | 55 | result += render_indexes(model) 56 | 57 | result += '}' 58 | return result 59 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/index.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pydbml.classes import Expression, Index, Column 4 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 5 | from pydbml.renderer.sql.default.utils import comment_to_sql 6 | 7 | 8 | def render_subject(subject: Any) -> str: 9 | if isinstance(subject, Column): 10 | return f'"{subject.name}"' 11 | elif isinstance(subject, Expression): 12 | return DefaultSQLRenderer.render(subject) 13 | else: 14 | return subject 15 | 16 | 17 | def render_pk(model: Index, keys: str) -> str: 18 | result = comment_to_sql(model.comment) if model.comment else '' 19 | result += f'PRIMARY KEY ({keys})' 20 | return result 21 | 22 | 23 | def create_components(model: Index, keys: str) -> str: 24 | components = [] 25 | if model.comment: 26 | components.append(comment_to_sql(model.comment)) 27 | 28 | components.append('CREATE ') 29 | 30 | if model.unique: 31 | components.append('UNIQUE ') 32 | 33 | components.append('INDEX ') 34 | 35 | if model.name: 36 | components.append(f'"{model.name}" ') 37 | if model.table: 38 | components.append(f'ON "{model.table.name}" ') 39 | 40 | if model.type: 41 | components.append(f'USING {model.type.upper()} ') 42 | components.append(f'({keys})') 43 | return ''.join(components) + ';' 44 | 45 | 46 | @DefaultSQLRenderer.renderer_for(Index) 47 | def render_index(model: Index) -> str: 48 | ''' 49 | Returns inline SQL of the index to be created separately from table 50 | definition: 51 | 52 | CREATE UNIQUE INDEX ON "products" USING HASH ("id"); 53 | 54 | But if it's a (composite) primary key index, returns an inline SQL for 55 | composite primary key to be used inside table definition: 56 | 57 | PRIMARY KEY ("id", "name") 58 | ''' 59 | 60 | keys = ', '.join(render_subject(s) for s in model.subjects) 61 | 62 | if model.pk: 63 | return render_pk(model, keys) 64 | 65 | return create_components(model, keys) 66 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pydbml.classes import Enum 4 | from pydbml.constants import ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY 5 | from pydbml.renderer.sql.default.utils import ( 6 | get_full_name_for_sql, 7 | reorder_tables_for_sql, 8 | ) 9 | 10 | 11 | class TestGetFullNameForSQL: 12 | @staticmethod 13 | def test_public(enum1: Enum) -> None: 14 | assert get_full_name_for_sql(enum1) == '"product status"' 15 | 16 | @staticmethod 17 | def test_schema(enum1: Enum) -> None: 18 | enum1.schema = "myschema" 19 | assert get_full_name_for_sql(enum1) == '"myschema"."product status"' 20 | 21 | 22 | def test_reorder_tables() -> None: 23 | t1 = Mock(name="table1") # 1 ref 24 | t2 = Mock(name="table2") # 2 refs 25 | t3 = Mock(name="table3") 26 | t4 = Mock(name="table4") # 1 ref 27 | t5 = Mock(name="table5") 28 | t6 = Mock(name="table6") # 3 refs 29 | t7 = Mock(name="table7") 30 | t8 = Mock(name="table8") 31 | t9 = Mock(name="table9") 32 | t10 = Mock(name="table10") 33 | 34 | refs = [ 35 | Mock(type=ONE_TO_MANY, table1=t1, table2=t2, inline=True), 36 | Mock(type=MANY_TO_ONE, table1=t4, table2=t3, inline=True), 37 | Mock(type=ONE_TO_MANY, table1=t6, table2=t2, inline=True), 38 | Mock(type=ONE_TO_MANY, table1=t7, table2=t6, inline=True), 39 | Mock(type=MANY_TO_ONE, table1=t6, table2=t8, inline=True), 40 | Mock(type=ONE_TO_MANY, table1=t9, table2=t6, inline=True), 41 | Mock( 42 | type=ONE_TO_MANY, table1=t1, table2=t2, inline=False 43 | ), # ignored not inline 44 | Mock(type=ONE_TO_MANY, table1=t10, table2=t1, inline=True), 45 | Mock(type=MANY_TO_MANY, table1=t1, table2=t2, inline=True), # ignored m2m 46 | ] 47 | original = [t1, t2, t3, t4, t5, t6, t7, t8, t9, t10] 48 | expected = [t6, t2, t1, t4, t3, t5, t7, t8, t9, t10] 49 | result = reorder_tables_for_sql(original, refs) # type: ignore 50 | assert expected == result 51 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/column.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from pydbml.classes import Column, Enum, Expression 4 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 5 | from pydbml.renderer.dbml.default.utils import comment_to_dbml, note_option_to_dbml, quote_string, prepare_text_for_dbml 6 | from pydbml.renderer.sql.default.utils import get_full_name_for_sql 7 | 8 | 9 | def default_to_str(val: Union[Expression, str, int, float]) -> str: 10 | if isinstance(val, str): 11 | if val.lower() in ('null', 'true', 'false'): 12 | return val.lower() 13 | else: 14 | return f"'{prepare_text_for_dbml(val)}'" 15 | elif isinstance(val, Expression): 16 | return val.dbml 17 | else: # int or float or bool 18 | return str(val) 19 | 20 | 21 | def render_options(model: Column) -> str: 22 | options = [ref.dbml for ref in model.get_refs() if ref.inline] 23 | if model.pk: 24 | options.append('pk') 25 | if model.autoinc: 26 | options.append('increment') 27 | if model.default is not None: 28 | options.append(f'default: {default_to_str(model.default)}') 29 | if model.unique: 30 | options.append('unique') 31 | if model.not_null: 32 | options.append('not null') 33 | if model.note: 34 | options.append(note_option_to_dbml(model.note)) 35 | if model.properties: 36 | if model.table and model.table.database and model.table.database.allow_properties: 37 | for key, value in model.properties.items(): 38 | options.append(f'{key}: {quote_string(value)}') 39 | 40 | if options: 41 | return f' [{", ".join(options)}]' 42 | return '' 43 | 44 | 45 | @DefaultDBMLRenderer.renderer_for(Column) 46 | def render_column(model: Column) -> str: 47 | result = comment_to_dbml(model.comment) if model.comment else '' 48 | result += f'"{model.name}" ' 49 | if isinstance(model.type, Enum): 50 | result += get_full_name_for_sql(model.type) 51 | else: 52 | result += model.type 53 | 54 | result += render_options(model) 55 | return result 56 | -------------------------------------------------------------------------------- /test/test_definitions/test_project.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyparsing import ParseSyntaxException 4 | from pyparsing import ParserElement 5 | 6 | from pydbml.definitions.project import project 7 | from pydbml.definitions.project import project_field 8 | 9 | 10 | ParserElement.set_default_whitespace_chars(' \t\r') 11 | 12 | 13 | class TestProjectField(TestCase): 14 | def test_ok(self) -> None: 15 | val = "field: 'value'" 16 | project_field.parse_string(val, parseAll=True) 17 | 18 | def test_nok(self) -> None: 19 | val = "field: value" 20 | with self.assertRaises(ParseSyntaxException): 21 | project_field.parse_string(val, parseAll=True) 22 | 23 | 24 | class TestProject(TestCase): 25 | def test_empty(self) -> None: 26 | val = 'project name {}' 27 | res = project.parse_string(val, parseAll=True) 28 | self.assertEqual(res[0].name, 'name') 29 | 30 | def test_fields(self) -> None: 31 | val = "project name {field1: 'value1' field2: 'value2'}" 32 | res = project.parse_string(val, parseAll=True) 33 | self.assertEqual(res[0].name, 'name') 34 | self.assertEqual(res[0].items['field1'], 'value1') 35 | self.assertEqual(res[0].items['field2'], 'value2') 36 | 37 | def test_fields_and_note(self) -> None: 38 | val = "project name {\nfield1: 'value1'\nfield2: 'value2'\nnote: 'note value'}" 39 | res = project.parse_string(val, parseAll=True) 40 | self.assertEqual(res[0].name, 'name') 41 | self.assertEqual(res[0].items['field1'], 'value1') 42 | self.assertEqual(res[0].items['field2'], 'value2') 43 | self.assertEqual(res[0].note.text, 'note value') 44 | 45 | def test_comment(self) -> None: 46 | val = "//comment before\nproject name {\nfield1: 'value1'\nfield2: 'value2'\nnote: 'note value'}" 47 | res = project.parse_string(val, parseAll=True) 48 | self.assertEqual(res[0].name, 'name') 49 | self.assertEqual(res[0].items['field1'], 'value1') 50 | self.assertEqual(res[0].items['field2'], 'value2') 51 | self.assertEqual(res[0].note.text, 'note value') 52 | self.assertEqual(res[0].comment, 'comment before') 53 | -------------------------------------------------------------------------------- /test_schema.dbml: -------------------------------------------------------------------------------- 1 | Project test_schema { 2 | author: 'dbml.org' 3 | note: 'This schema is used for PyDBML doctest' 4 | } 5 | 6 | Enum "orders_status" { 7 | "created" 8 | "running" 9 | "done" 10 | "failure" 11 | } 12 | 13 | Enum "product status" { 14 | "Out of Stock" 15 | "In Stock" 16 | } 17 | 18 | Table "orders" [headercolor: #fff] { 19 | "id" int [pk, increment] 20 | "user_id" int [ 21 | unique, 22 | not null 23 | ] 24 | "status" orders_status 25 | "created_at" varchar 26 | } 27 | 28 | Table "order_items" { 29 | "order_id" int 30 | "product_id" int 31 | "quantity" int [default: 1] 32 | } 33 | 34 | Table "products" { 35 | "id" int [pk] 36 | "name" varchar 37 | "merchant_id" int [not null] 38 | "price" int 39 | "status" "product status" 40 | "created_at" datetime [default: `now()`] 41 | 42 | Indexes { 43 | (merchant_id, status) [name: "product_status"] 44 | id [type: hash, unique] 45 | } 46 | } 47 | 48 | Table "users" { 49 | "id" int [pk] 50 | "full_name" varchar 51 | "email" varchar [unique] 52 | "gender" varchar 53 | "date_of_birth" varchar 54 | "created_at" varchar 55 | "country_code" int 56 | } 57 | 58 | Ref:"orders"."id" < "order_items"."order_id" 59 | 60 | TableGroup g1 [note: 'test note', color: #FFF] { 61 | users 62 | merchants 63 | note: 'test note 2' 64 | } 65 | 66 | TableGroup g2 { 67 | countries 68 | orders 69 | } 70 | 71 | Table "merchants" { 72 | "id" int [pk] 73 | "merchant_name" varchar 74 | "country_code" int 75 | "created_at" varchar 76 | "admin_id" int 77 | } 78 | 79 | 80 | Ref:"products"."id" < "order_items"."product_id" [update: set default, delete: set null] 81 | 82 | Ref:"countries"."code" < "users"."country_code" 83 | 84 | Ref:"countries"."code" < "merchants"."country_code" 85 | 86 | Ref:"merchants"."id" < "products"."merchant_id" 87 | 88 | Ref:"users"."id" < "merchants"."admin_id" 89 | 90 | Table "countries" { 91 | "code" int [pk] 92 | "name" varchar 93 | "continent_name" varchar 94 | } 95 | 96 | Note sticky_note1 { 97 | 'One line note' 98 | } 99 | 100 | Note sticky_note2 { 101 | ''' 102 | # Title 103 | body 104 | ''' 105 | } 106 | -------------------------------------------------------------------------------- /test/test_blueprints/test_note.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Note 4 | from pydbml.parser.blueprints import NoteBlueprint 5 | 6 | 7 | class TestNote(TestCase): 8 | def test_build(self) -> None: 9 | bp = NoteBlueprint(text='Note text') 10 | result = bp.build() 11 | self.assertIsInstance(result, Note) 12 | self.assertEqual(result.text, bp.text) 13 | 14 | def test_preformat_not_needed(self): 15 | oneline = 'One line of note text' 16 | multiline = 'Multiline\nnote\n\ntext' 17 | long_line = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Aspernatur quidem adipisci, impedit, ut illum dolorum consequatur odio voluptate numquam ea itaque excepturi, a libero placeat corrupti. Amet beatae suscipit necessitatibus. Ea expedita explicabo iste quae rem aliquam minus cumque eveniet enim delectus, alias aut impedit quaerat quia ex, aliquid sint amet iusto rerum! Sunt deserunt ea saepe corrupti officiis. Assumenda.' 18 | 19 | bp = NoteBlueprint(text=oneline) 20 | self.assertEqual(bp._preformat_text(), oneline) 21 | bp = NoteBlueprint(text=multiline) 22 | self.assertEqual(bp._preformat_text(), multiline) 23 | bp = NoteBlueprint(text=long_line) 24 | self.assertEqual(bp._preformat_text(), long_line) 25 | 26 | def test_preformat_needed(self): 27 | uniform_indentation = ' line1\n line2\n line3' 28 | varied_indentation = ' line1\n line2\n\n line3' 29 | empty_lines = '\n\n\n\n\n\n\nline1\nline2\nline3\n\n\n\n\n\n\n' 30 | empty_indented_lines = '\n \n\n \n\n line1\n line2\n line3\n\n\n\n \n\n\n' 31 | 32 | exptected = 'line1\nline2\nline3' 33 | bp = NoteBlueprint(text=uniform_indentation) 34 | self.assertEqual(bp._preformat_text(), exptected) 35 | 36 | exptected = 'line1\n line2\n\n line3' 37 | bp = NoteBlueprint(text=varied_indentation) 38 | self.assertEqual(bp._preformat_text(), exptected) 39 | 40 | exptected = 'line1\nline2\nline3' 41 | bp = NoteBlueprint(text=empty_lines) 42 | self.assertEqual(bp._preformat_text(), exptected) 43 | 44 | exptected = 'line1\nline2\nline3' 45 | bp = NoteBlueprint(text=empty_indented_lines) 46 | self.assertEqual(bp._preformat_text(), exptected) 47 | -------------------------------------------------------------------------------- /pydbml/renderer/dbml/default/reference.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from textwrap import indent 3 | from typing import List 4 | 5 | from pydbml.classes import Reference, Column 6 | from pydbml.exceptions import TableNotFoundError, DBMLError 7 | from pydbml.renderer.dbml.default.renderer import DefaultDBMLRenderer 8 | from pydbml.renderer.dbml.default.utils import comment_to_dbml 9 | from .table import get_full_name_for_dbml 10 | 11 | 12 | def validate_for_dbml(model: Reference): 13 | for col in chain(model.col1, model.col2): 14 | if col.table is None: 15 | raise TableNotFoundError(f'Table on {col} is not set') 16 | 17 | 18 | def render_inline_reference(model: Reference) -> str: 19 | # settings are ignored for inline ref 20 | if len(model.col2) > 1: 21 | raise DBMLError('Cannot render DBML: composite ref cannot be inline') 22 | table_name = get_full_name_for_dbml(model.col2[0].table) 23 | return f'ref: {model.type} {table_name}."{model.col2[0].name}"' 24 | 25 | 26 | def render_col(col: List[Column]) -> str: 27 | if len(col) == 1: 28 | return f'"{col[0].name}"' 29 | else: 30 | names = (f'"{c.name}"' for c in col) 31 | return f'({", ".join(names)})' 32 | 33 | 34 | def render_options(model: Reference) -> str: 35 | options = [] 36 | if model.on_update: 37 | options.append(f'update: {model.on_update}') 38 | if model.on_delete: 39 | options.append(f'delete: {model.on_delete}') 40 | if options: 41 | return f' [{", ".join(options)}]' 42 | return '' 43 | 44 | 45 | def render_not_inline_reference(model: Reference) -> str: 46 | result = comment_to_dbml(model.comment) if model.comment else '' 47 | result += 'Ref' 48 | if model.name: 49 | result += f' {model.name}' 50 | 51 | result += ( 52 | ' {\n ' # type: ignore 53 | f'{get_full_name_for_dbml(model.table1)}.{render_col(model.col1)} ' 54 | f'{model.type} ' 55 | f'{get_full_name_for_dbml(model.table2)}.{render_col(model.col2)}' 56 | f'{render_options(model)}' 57 | '\n}' 58 | ) 59 | return result 60 | 61 | 62 | @DefaultDBMLRenderer.renderer_for(Reference) 63 | def render_reference(model: Reference) -> str: 64 | validate_for_dbml(model) 65 | if model.inline: 66 | return render_inline_reference(model) 67 | else: 68 | return render_not_inline_reference(model) 69 | -------------------------------------------------------------------------------- /pydbml/_classes/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import Tuple 3 | 4 | from pydbml.exceptions import AttributeMissingError 5 | 6 | 7 | class SQLObject: 8 | ''' 9 | Base class for all SQL objects. 10 | ''' 11 | required_attributes: Tuple[str, ...] = () 12 | dont_compare_fields: Tuple[str, ...] = () 13 | 14 | def check_attributes_for_sql(self): 15 | ''' 16 | Check if all attributes, required for rendering SQL are set in the 17 | instance. If some attribute is missing, raise AttributeMissingError 18 | ''' 19 | for attr in self.required_attributes: 20 | if getattr(self, attr) is None: 21 | raise AttributeMissingError( 22 | f'Cannot render SQL. Missing required attribute "{attr}".' 23 | ) 24 | @property 25 | def sql(self) -> str: 26 | if hasattr(self, 'database') and self.database is not None: 27 | renderer = self.database.sql_renderer 28 | else: 29 | from pydbml.renderer.sql.default import DefaultSQLRenderer 30 | renderer = DefaultSQLRenderer 31 | 32 | return renderer.render(self) 33 | 34 | def __setattr__(self, name: str, value: Any): 35 | """ 36 | Required for type testing with MyPy. 37 | """ 38 | super().__setattr__(name, value) 39 | 40 | def __eq__(self, other: object) -> bool: 41 | """ 42 | Two instances of the same SQLObject subclass are equal if all their 43 | attributes are equal. 44 | """ 45 | 46 | if not isinstance(other, self.__class__): 47 | return False 48 | # not comparing those because they are circular references 49 | 50 | self_dict = dict(self.__dict__) 51 | other_dict = dict(other.__dict__) 52 | 53 | for field in self.dont_compare_fields: 54 | self_dict.pop(field, None) 55 | other_dict.pop(field, None) 56 | 57 | return self_dict == other_dict 58 | 59 | 60 | class DBMLObject: 61 | '''Base class for all DBML objects.''' 62 | @property 63 | def dbml(self) -> str: 64 | if hasattr(self, 'database') and self.database is not None: 65 | renderer = self.database.dbml_renderer 66 | else: 67 | from pydbml.renderer.dbml.default import DefaultDBMLRenderer 68 | renderer = DefaultDBMLRenderer 69 | 70 | return renderer.render(self) 71 | -------------------------------------------------------------------------------- /pydbml/definitions/enum.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _ 4 | from .common import _c 5 | from .common import c 6 | from .common import end 7 | from .common import n 8 | from .common import note 9 | from .generic import name 10 | from pydbml.parser.blueprints import EnumBlueprint 11 | from pydbml.parser.blueprints import EnumItemBlueprint 12 | 13 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 14 | 15 | enum_settings = '[' + _ - note('note') + _ - ']' + c 16 | 17 | 18 | def parse_enum_settings(s, loc, tok): 19 | ''' 20 | [note: "note content"] // comment 21 | ''' 22 | result = {} 23 | if 'note' in tok: 24 | result['note'] = tok['note'] 25 | if 'comment' in tok: 26 | result['comment'] = tok['comment'][0] 27 | return result 28 | 29 | 30 | enum_settings.set_parse_action(parse_enum_settings) 31 | 32 | enum_item = _c + (name('name') + c + enum_settings('settings')[0, 1]) 33 | 34 | 35 | def parse_enum_item(s, loc, tok): 36 | ''' 37 | student [note: "is stupid"] 38 | ''' 39 | init_dict = {'name': tok['name']} 40 | if 'settings' in tok: 41 | init_dict.update(tok['settings']) 42 | # comments after settings have priority 43 | if 'comment' in tok['settings']: 44 | init_dict['comment'] = tok['settings']['comment'] 45 | if 'comment' not in init_dict and 'comment_before' in tok: 46 | comment = '\n'.join(c[0] for c in tok['comment_before']) 47 | init_dict['comment'] = comment 48 | 49 | return EnumItemBlueprint(**init_dict) 50 | 51 | 52 | enum_item.set_parse_action(parse_enum_item) 53 | 54 | enum_body = enum_item[1, ...] 55 | 56 | enum_name = pp.Combine(name("schema") + '.' + name("name")) | name("name") 57 | 58 | enum = _c + ( 59 | pp.CaselessLiteral('enum') 60 | - enum_name + _ 61 | - '{' 62 | + enum_body('items') + n 63 | - '}' 64 | ) + end 65 | 66 | 67 | def parse_enum(s, loc, tok): 68 | ''' 69 | enum members { 70 | janitor 71 | student 72 | teacher 73 | headmaster 74 | } 75 | ''' 76 | init_dict = { 77 | 'name': tok['name'], 78 | 'items': list(tok['items']) 79 | } 80 | 81 | if 'schema' in tok: 82 | init_dict['schema'] = tok['schema'] 83 | 84 | if 'comment_before' in tok: 85 | comment = '\n'.join(c[0] for c in tok['comment_before']) 86 | init_dict['comment'] = comment 87 | 88 | return EnumBlueprint(**init_dict) 89 | 90 | 91 | enum.set_parse_action(parse_enum) 92 | -------------------------------------------------------------------------------- /pydbml/_classes/index.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from typing import Literal 3 | from typing import Optional 4 | from typing import TYPE_CHECKING 5 | from typing import Union 6 | 7 | from .base import SQLObject, DBMLObject 8 | from .column import Column 9 | from .expression import Expression 10 | from .note import Note 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from .table import Table 14 | 15 | 16 | class Index(SQLObject, DBMLObject): 17 | '''Class representing index.''' 18 | required_attributes = ('subjects', 'table') 19 | dont_compare_fields = ('table',) 20 | 21 | def __init__(self, 22 | subjects: List[Union[str, Column, Expression]], 23 | name: Optional[str] = None, 24 | unique: bool = False, 25 | type: Optional[ 26 | Literal[ 27 | # https://www.postgresql.org/docs/current/indexes-types.html 28 | "brin", 29 | "btree", 30 | "gin", 31 | "gist", 32 | "hash", 33 | "spgist", 34 | ] 35 | ] = None, 36 | pk: bool = False, 37 | note: Optional[Union[Note, str]] = None, 38 | comment: Optional[str] = None): 39 | self.subjects = subjects 40 | self.table: Optional[Table] = None 41 | 42 | self.name = name if name else None 43 | self.unique = unique 44 | self.type = type 45 | self.pk = pk 46 | self.note = Note(note) 47 | self.comment = comment 48 | 49 | @property 50 | def note(self): 51 | return self._note 52 | 53 | @note.setter 54 | def note(self, val: Note) -> None: 55 | self._note = val 56 | val.parent = self 57 | 58 | @property 59 | def subject_names(self): 60 | ''' 61 | Returns updated list of subject names. 62 | ''' 63 | return [s.name if isinstance(s, Column) else str(s) for s in self.subjects] 64 | 65 | def __repr__(self): 66 | ''' 67 | 68 | ''' 69 | 70 | table_name = self.table.name if self.table else None 71 | return f"" 72 | 73 | def __str__(self): 74 | ''' 75 | Index(test[col, (c*2)]) 76 | ''' 77 | 78 | table_name = self.table.name if self.table else '' 79 | subjects = ', '.join(self.subject_names) 80 | return f"Index({table_name}[{subjects}])" 81 | -------------------------------------------------------------------------------- /pydbml/_classes/enum.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | from typing import List 3 | from typing import Optional 4 | from typing import Union 5 | 6 | from .base import SQLObject, DBMLObject 7 | from .note import Note 8 | 9 | 10 | class EnumItem(SQLObject, DBMLObject): 11 | '''Single enum item''' 12 | 13 | required_attributes = ('name',) 14 | 15 | def __init__(self, 16 | name: str, 17 | note: Optional[Union[Note, str]] = None, 18 | comment: Optional[str] = None): 19 | self.name = name 20 | self.note = Note(note) 21 | self.comment = comment 22 | 23 | @property 24 | def note(self): 25 | return self._note 26 | 27 | @note.setter 28 | def note(self, val: Note) -> None: 29 | self._note = val 30 | val.parent = self 31 | 32 | def __repr__(self): 33 | '''''' 34 | return f'' 35 | 36 | def __str__(self): 37 | '''en-US''' 38 | return self.name 39 | 40 | 41 | class Enum(SQLObject, DBMLObject): 42 | required_attributes = ('name', 'schema', 'items') 43 | 44 | def __init__(self, 45 | name: str, 46 | items: Iterable[Union['EnumItem', str]], 47 | schema: str = 'public', 48 | comment: Optional[str] = None): 49 | self.database = None 50 | self.name = name 51 | self.schema = schema 52 | self.comment = comment 53 | self.items: List[EnumItem] = [] 54 | for item in items: 55 | self.add_item(item) 56 | 57 | def add_item(self, item: Union['EnumItem', str]) -> None: 58 | if isinstance(item, EnumItem): 59 | self.items.append(item) 60 | elif isinstance(item, str): 61 | self.items.append(EnumItem(item)) 62 | 63 | def __getitem__(self, key: int) -> EnumItem: 64 | return self.items[key] 65 | 66 | def __iter__(self): 67 | return iter(self.items) 68 | 69 | def __repr__(self): 70 | ''' 71 | >>> en = EnumItem('en-US') 72 | >>> ru = EnumItem('ru-RU') 73 | >>> Enum('languages', [en, ru]) 74 | 75 | ''' 76 | 77 | item_names = [i.name for i in self.items] 78 | classname = self.__class__.__name__ 79 | return f'<{classname} {self.name!r}, {item_names!r}>' 80 | 81 | def __str__(self): 82 | ''' 83 | >>> en = EnumItem('en-US') 84 | >>> ru = EnumItem('ru-RU') 85 | >>> print(Enum('languages', [en, ru])) 86 | languages 87 | ''' 88 | 89 | return self.name 90 | -------------------------------------------------------------------------------- /test/test_blueprints/test_table_group.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | 4 | from pydbml.classes import Table 5 | from pydbml.classes import TableGroup 6 | from pydbml.exceptions import ValidationError 7 | from pydbml.parser.blueprints import TableGroupBlueprint 8 | 9 | 10 | class TestTableGroupBlueprint(TestCase): 11 | def test_build(self) -> None: 12 | bp = TableGroupBlueprint( 13 | name='TestTableGroup', 14 | items=['table1', 'table2'], 15 | comment='Comment text' 16 | ) 17 | with self.assertRaises(RuntimeError): 18 | bp.build() 19 | 20 | parserMock = Mock() 21 | parserMock.locate_table.side_effect = [ 22 | Table(name='table1'), 23 | Table(name='table2') 24 | ] 25 | bp.parser = parserMock 26 | result = bp.build() 27 | self.assertIsInstance(result, TableGroup) 28 | self.assertEqual(parserMock.locate_table.call_count, 2) 29 | for i in result.items: 30 | self.assertIsInstance(i, Table) 31 | 32 | def test_build_with_schema(self) -> None: 33 | bp = TableGroupBlueprint( 34 | name='TestTableGroup', 35 | items=['myschema.table1', 'myschema.table2'], 36 | comment='Comment text' 37 | ) 38 | with self.assertRaises(RuntimeError): 39 | bp.build() 40 | 41 | parserMock = Mock() 42 | parserMock.locate_table.side_effect = [ 43 | Table(name='table1', schema='myschema'), 44 | Table(name='table2', schema='myschema') 45 | ] 46 | bp.parser = parserMock 47 | result = bp.build() 48 | self.assertIsInstance(result, TableGroup) 49 | locate_table_calls = parserMock.locate_table.call_args_list 50 | self.assertEqual(len(locate_table_calls), 2) 51 | self.assertEqual(locate_table_calls[0].args, ('myschema', 'table1')) 52 | self.assertEqual(locate_table_calls[1].args, ('myschema', 'table2')) 53 | for i in result.items: 54 | self.assertIsInstance(i, Table) 55 | 56 | def test_duplicate_table(self) -> None: 57 | bp = TableGroupBlueprint( 58 | name='TestTableGroup', 59 | items=['table1', 'table2', 'table1'], 60 | comment='Comment text' 61 | ) 62 | 63 | parserMock = Mock() 64 | parserMock.locate_table.side_effect = [ 65 | Table(name='table1'), 66 | Table(name='table2'), 67 | Table(name='table1') 68 | ] 69 | bp.parser = parserMock 70 | with self.assertRaises(ValidationError): 71 | bp.build() 72 | -------------------------------------------------------------------------------- /test/test_blueprints/test_reference.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | 4 | from pydbml.classes import Column 5 | from pydbml.classes import Reference 6 | from pydbml.classes import Table 7 | from pydbml.exceptions import ColumnNotFoundError 8 | from pydbml.exceptions import TableNotFoundError 9 | from pydbml.parser.blueprints import ReferenceBlueprint 10 | 11 | 12 | class TestReferenceBlueprint(TestCase): 13 | def test_build_minimal(self) -> None: 14 | bp = ReferenceBlueprint( 15 | type='>', 16 | inline=True, 17 | table1='table1', 18 | col1='col1', 19 | table2='table2', 20 | col2='col2', 21 | ) 22 | 23 | t1 = Table( 24 | name='table1' 25 | ) 26 | c1 = Column(name='col1', type='Number') 27 | t1.add_column(c1) 28 | t2 = Table( 29 | name='table2' 30 | ) 31 | c2 = Column(name='col2', type='Varchar') 32 | t2.add_column(c2) 33 | 34 | with self.assertRaises(RuntimeError): 35 | bp.build() 36 | 37 | parserMock = Mock() 38 | parserMock.locate_table.side_effect = [t1, t2] 39 | bp.parser = parserMock 40 | result = bp.build() 41 | self.assertIsInstance(result, Reference) 42 | self.assertEqual(result.type, bp.type) 43 | self.assertEqual(result.inline, bp.inline) 44 | self.assertEqual(parserMock.locate_table.call_count, 2) 45 | self.assertEqual(result.col1, [c1]) 46 | self.assertEqual(result.col2, [c2]) 47 | 48 | def test_tables_and_cols_are_not_set(self) -> None: 49 | bp = ReferenceBlueprint( 50 | type='>', 51 | inline=True, 52 | table1=None, 53 | col1='col1', 54 | table2='table2', 55 | col2='col2' 56 | ) 57 | with self.assertRaises(TableNotFoundError): 58 | bp.build() 59 | 60 | bp.table1 = 'table1' 61 | bp.table2 = None 62 | with self.assertRaises(TableNotFoundError): 63 | bp.build() 64 | 65 | bp.table2 = 'table2' 66 | bp.col1 = None 67 | with self.assertRaises(ColumnNotFoundError): 68 | bp.build() 69 | 70 | bp.col1 = 'col1' 71 | bp.col2 = None 72 | with self.assertRaises(ColumnNotFoundError): 73 | bp.build() 74 | 75 | def test_tables_and_cols_are_set(self) -> None: 76 | bp = ReferenceBlueprint( 77 | type='>', 78 | inline=True, 79 | table1='table1', 80 | col1='col1', 81 | table2='table2', 82 | col2=None 83 | ) 84 | with self.assertRaises(ColumnNotFoundError): 85 | bp.build() 86 | -------------------------------------------------------------------------------- /docs/properties.md: -------------------------------------------------------------------------------- 1 | # Arbitrary Properties 2 | 3 | Since 1.1.0 PyDBML supports arbitrary properties in Table and Column definitions. Arbitrary properties is a dictionary of key-value pairs that can be added to any Table or Column manually, or parsed from a DBML file. This may be useful for extending the standard DBML syntax or keeping additional information in the schema. 4 | 5 | Arbitrary properties are turned off by default. To enable parsing properties in DBML files, set `allow_properties` argument to `True` in the parser call. To enable rendering properties in the output DBML of an existing database, set `allow_properties` database attribute to `True`. 6 | 7 | ## Properties in DBML 8 | 9 | In a DBML file arbitrary properties are defined like this: 10 | 11 | ```python 12 | >>> dbml_str = ''' 13 | ... Table "products" { 14 | ... "id" integer 15 | ... "name" varchar [col_prop: 'some value'] 16 | ... table_prop: 'another value' 17 | ... }''' 18 | 19 | ``` 20 | 21 | In this example we've added a property `col_prop` to the column `name` and a property `table_prop` to the table `products`. Note that property values must me single-quoted strings. Multiline strings (with `'''`) are supported. 22 | 23 | Now let's parse this DBML string: 24 | 25 | ```python 26 | >>> from pydbml import PyDBML 27 | >>> mydb = PyDBML(dbml_str, allow_properties=True) 28 | >>> mydb.tables[0].columns[1].properties 29 | {'col_prop': 'some value'} 30 | >>> mydb.tables[0].properties 31 | {'table_prop': 'another value'} 32 | 33 | ``` 34 | 35 | The `allow_properties=True` argument is crucial here. Without it, the parser will raise syntax errors. 36 | 37 | ## Rendering Properties 38 | 39 | To render properties in the output DBML, set `allow_properties` attribute of the Database object to `True`. If you parsed the DBML with `allow_properties=True`, the result database will already have this attribute set to `True`. 40 | 41 | We will reuse the `mydb` database from the previous example: 42 | 43 | ```python 44 | >>> print(mydb.allow_properties) 45 | True 46 | 47 | ``` 48 | 49 | Let's set a new property on the table and render the DBML: 50 | 51 | ```python 52 | >>> mydb.tables[0].properties['new_prop'] = 'Multiline\nproperty\nvalue' 53 | >>> print(mydb.dbml) 54 | Table "products" { 55 | "id" integer 56 | "name" varchar [col_prop: 'some value'] 57 | 58 | table_prop: 'another value' 59 | new_prop: ''' 60 | Multiline 61 | property 62 | value''' 63 | } 64 | 65 | ``` 66 | 67 | As you see, properties are also rendered in the output DBML correctly. But if `allow_properties` is set to `False`, the properties will be ignored: 68 | 69 | ```python 70 | >>> mydb.allow_properties = False 71 | >>> print(mydb.dbml) 72 | Table "products" { 73 | "id" integer 74 | "name" varchar 75 | } 76 | 77 | ``` 78 | -------------------------------------------------------------------------------- /test/test_blueprints/test_sticky_note.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml._classes.sticky_note import StickyNote 4 | from pydbml.parser.blueprints import StickyNoteBlueprint 5 | 6 | class TestNote(TestCase): 7 | def test_build(self) -> None: 8 | bp = StickyNoteBlueprint(name='mynote', text='Note text') 9 | result = bp.build() 10 | self.assertIsInstance(result, StickyNote) 11 | self.assertEqual(result.name, bp.name) 12 | self.assertEqual(result.text, bp.text) 13 | 14 | def test_preformat_not_needed(self): 15 | oneline = 'One line of note text' 16 | multiline = 'Multiline\nnote\n\ntext' 17 | long_line = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Aspernatur quidem adipisci, impedit, ut illum dolorum consequatur odio voluptate numquam ea itaque excepturi, a libero placeat corrupti. Amet beatae suscipit necessitatibus. Ea expedita explicabo iste quae rem aliquam minus cumque eveniet enim delectus, alias aut impedit quaerat quia ex, aliquid sint amet iusto rerum! Sunt deserunt ea saepe corrupti officiis. Assumenda.' 18 | 19 | bp = StickyNoteBlueprint(name='mynote', text=oneline) 20 | self.assertEqual(bp.name, bp.name) 21 | self.assertEqual(bp._preformat_text(), oneline) 22 | bp = StickyNoteBlueprint(name='mynote', text=multiline) 23 | self.assertEqual(bp.name, bp.name) 24 | self.assertEqual(bp._preformat_text(), multiline) 25 | bp = StickyNoteBlueprint(name='mynote', text=long_line) 26 | self.assertEqual(bp.name, bp.name) 27 | self.assertEqual(bp._preformat_text(), long_line) 28 | 29 | def test_preformat_needed(self): 30 | uniform_indentation = ' line1\n line2\n line3' 31 | varied_indentation = ' line1\n line2\n\n line3' 32 | empty_lines = '\n\n\n\n\n\n\nline1\nline2\nline3\n\n\n\n\n\n\n' 33 | empty_indented_lines = '\n \n\n \n\n line1\n line2\n line3\n\n\n\n \n\n\n' 34 | 35 | exptected = 'line1\nline2\nline3' 36 | bp = StickyNoteBlueprint(name='mynote', text=uniform_indentation) 37 | self.assertEqual(bp._preformat_text(), exptected) 38 | self.assertEqual(bp.name, bp.name) 39 | 40 | exptected = 'line1\n line2\n\n line3' 41 | bp = StickyNoteBlueprint(name='mynote', text=varied_indentation) 42 | self.assertEqual(bp._preformat_text(), exptected) 43 | self.assertEqual(bp.name, bp.name) 44 | 45 | exptected = 'line1\nline2\nline3' 46 | bp = StickyNoteBlueprint(name='mynote', text=empty_lines) 47 | self.assertEqual(bp._preformat_text(), exptected) 48 | self.assertEqual(bp.name, bp.name) 49 | 50 | exptected = 'line1\nline2\nline3' 51 | bp = StickyNoteBlueprint(name='mynote', text=empty_indented_lines) 52 | self.assertEqual(bp._preformat_text(), exptected) 53 | self.assertEqual(bp.name, bp.name) 54 | -------------------------------------------------------------------------------- /test/test_blueprints/test_column.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import Mock 3 | 4 | from pydbml.classes import Column 5 | from pydbml.classes import Enum 6 | from pydbml.classes import EnumItem 7 | from pydbml.classes import Note 8 | from pydbml.database import Database 9 | from pydbml.parser.blueprints import ColumnBlueprint 10 | from pydbml.parser.blueprints import NoteBlueprint 11 | 12 | 13 | class TestColumn(TestCase): 14 | def test_build_minimal(self) -> None: 15 | bp = ColumnBlueprint( 16 | name='testcol', 17 | type='varchar' 18 | ) 19 | result = bp.build() 20 | self.assertIsInstance(result, Column) 21 | self.assertEqual(result.name, bp.name) 22 | self.assertEqual(result.type, bp.type) 23 | 24 | def test_build_full(self) -> None: 25 | bp = ColumnBlueprint( 26 | name='id', 27 | type='number', 28 | unique=True, 29 | not_null=True, 30 | pk=True, 31 | autoinc=True, 32 | default=0, 33 | note=NoteBlueprint(text='note text'), 34 | comment='Col commment' 35 | ) 36 | result = bp.build() 37 | self.assertIsInstance(result, Column) 38 | self.assertEqual(result.name, bp.name) 39 | self.assertEqual(result.type, bp.type) 40 | self.assertEqual(result.unique, bp.unique) 41 | self.assertEqual(result.not_null, bp.not_null) 42 | self.assertEqual(result.pk, bp.pk) 43 | self.assertEqual(result.autoinc, bp.autoinc) 44 | self.assertEqual(result.default, bp.default) 45 | self.assertIsInstance(result.note, Note) 46 | self.assertEqual(result.note.text, bp.note.text) 47 | self.assertEqual(result.comment, bp.comment) 48 | 49 | def test_enum_type(self) -> None: 50 | s = Database() 51 | e = Enum( 52 | 'myenum', 53 | items=[ 54 | EnumItem('i1'), 55 | EnumItem('i2') 56 | ] 57 | ) 58 | s.add(e) 59 | parser = Mock() 60 | parser.database = s 61 | 62 | bp = ColumnBlueprint( 63 | name='testcol', 64 | type='myenum' 65 | ) 66 | bp.parser = parser 67 | result = bp.build() 68 | self.assertIs(result.type, e) 69 | 70 | def test_enum_type_schema(self) -> None: 71 | s = Database() 72 | e = Enum( 73 | 'myenum', 74 | schema='myschema', 75 | items=[ 76 | EnumItem('i1'), 77 | EnumItem('i2') 78 | ] 79 | ) 80 | s.add(e) 81 | parser = Mock() 82 | parser.database = s 83 | 84 | bp = ColumnBlueprint( 85 | name='testcol', 86 | type='myschema.myenum' 87 | ) 88 | bp.parser = parser 89 | result = bp.build() 90 | self.assertIs(result.type, e) 91 | -------------------------------------------------------------------------------- /test/test_renderer/test_sql/test_default/test_index.py: -------------------------------------------------------------------------------- 1 | from pydbml.classes import Column, Expression, Index 2 | from pydbml.renderer.sql.default.index import render_subject, render_index, render_pk 3 | 4 | 5 | class TestRenderSubject: 6 | @staticmethod 7 | def test_column(simple_column: Column) -> None: 8 | expected = '"id"' 9 | assert render_subject(simple_column) == expected 10 | 11 | @staticmethod 12 | def test_expression(expression1: Expression) -> None: 13 | expected = "(SUM(amount))" 14 | assert render_subject(expression1) == expected 15 | 16 | @staticmethod 17 | def test_other() -> None: 18 | expected = "test" 19 | assert render_subject(expected) == expected 20 | 21 | 22 | class TestRenderPK: 23 | @staticmethod 24 | def test_comment(index1: Index) -> None: 25 | index1.comment = "Test comment" 26 | expected = '-- Test comment\nPRIMARY KEY ("name")' 27 | assert render_pk(index1, '"name"') == expected 28 | 29 | @staticmethod 30 | def test_no_comment(index1: Index) -> None: 31 | expected = 'PRIMARY KEY ("name")' 32 | assert render_pk(index1, '"name"') == expected 33 | 34 | 35 | class TestRenderComponents: 36 | @staticmethod 37 | def test_comment(index1: Index) -> None: 38 | index1.comment = "Test comment" 39 | expected = '-- Test comment\nCREATE INDEX ON "products" ("name");' 40 | assert render_index(index1) == expected 41 | 42 | @staticmethod 43 | def test_unique(index1: Index) -> None: 44 | index1.unique = True 45 | expected = 'CREATE UNIQUE INDEX ON "products" ("name");' 46 | assert render_index(index1) == expected 47 | 48 | @staticmethod 49 | def test_name(index1: Index) -> None: 50 | index1.name = "test" 51 | expected = 'CREATE INDEX "test" ON "products" ("name");' 52 | assert render_index(index1) == expected 53 | 54 | @staticmethod 55 | def test_no_table(index1: Index) -> None: 56 | index1.table = None 57 | expected = 'CREATE INDEX ("name");' 58 | assert render_index(index1) == expected 59 | 60 | @staticmethod 61 | def test_type(index1: Index) -> None: 62 | index1.type = "hash" 63 | expected = 'CREATE INDEX ON "products" USING HASH ("name");' 64 | assert render_index(index1) == expected 65 | 66 | 67 | class TestRenderIndex: 68 | @staticmethod 69 | def test_render_index(index1: Index) -> None: 70 | index1.comment = "Test comment" 71 | index1.unique = True 72 | index1.name = "test" 73 | index1.type = "hash" 74 | 75 | expected = '-- Test comment\nCREATE UNIQUE INDEX "test" ON "products" USING HASH ("name");' 76 | assert render_index(index1) == expected 77 | 78 | @staticmethod 79 | def test_render_pk(index1: Index) -> None: 80 | index1.pk = True 81 | expected = 'PRIMARY KEY ("name")' 82 | assert render_index(index1) == expected 83 | -------------------------------------------------------------------------------- /test/test_definitions/test_table_group.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from unittest import TestCase 3 | 4 | from pyparsing import ParserElement 5 | 6 | from pydbml.definitions.table_group import table_group 7 | 8 | 9 | ParserElement.set_default_whitespace_chars(" \t\r") 10 | 11 | 12 | class TestTableGroup(TestCase): 13 | def test_empty(self) -> None: 14 | val = "TableGroup name {}" 15 | res = table_group.parse_string(val, parseAll=True) 16 | self.assertEqual(res[0].name, "name") 17 | 18 | def test_fields(self) -> None: 19 | val = "TableGroup name {table1 table2}" 20 | res = table_group.parse_string(val, parseAll=True) 21 | self.assertEqual(res[0].name, "name") 22 | self.assertEqual(res[0].items, ["table1", "table2"]) 23 | 24 | def test_comment(self) -> None: 25 | val = "//comment before\nTableGroup name\n{\ntable1\ntable2\n}" 26 | res = table_group.parse_string(val, parseAll=True) 27 | self.assertEqual(res[0].name, "name") 28 | self.assertEqual(res[0].items, ["table1", "table2"]) 29 | self.assertEqual(res[0].comment, "comment before") 30 | 31 | def test_note_settings(self) -> None: 32 | val = "TableGroup name [note: 'My note'] \n{\ntable1\ntable2\n}" 33 | res = table_group.parse_string(val, parseAll=True) 34 | self.assertEqual(res[0].name, "name") 35 | self.assertEqual(res[0].items, ["table1", "table2"]) 36 | self.assertEqual(res[0].note.text, "My note") 37 | 38 | def test_color(self) -> None: 39 | val = "TableGroup name [color: #FFF] \n{\ntable1\ntable2\n}" 40 | res = table_group.parse_string(val, parseAll=True) 41 | self.assertEqual(res[0].name, "name") 42 | self.assertEqual(res[0].items, ["table1", "table2"]) 43 | self.assertEqual(res[0].color, "#FFF") 44 | 45 | def test_all_settings(self) -> None: 46 | val = "TableGroup name [color: #FFF, note: 'My note'] \n{\ntable1\ntable2\n}" 47 | res = table_group.parse_string(val, parseAll=True) 48 | self.assertEqual(res[0].name, "name") 49 | self.assertEqual(res[0].items, ["table1", "table2"]) 50 | self.assertEqual(res[0].color, "#FFF") 51 | self.assertEqual(res[0].note.text, "My note") 52 | 53 | def test_note_body(self) -> None: 54 | val = dedent("""\ 55 | TableGroup name { 56 | table1 57 | Note: ''' 58 | Note line1 59 | Note line2 60 | ''' 61 | table2 62 | } 63 | """) 64 | res = table_group.parse_string(val, parseAll=True) 65 | self.assertEqual(res[0].name, "name") 66 | self.assertEqual(res[0].items, ["table1", "table2"]) 67 | self.assertIn("Note line1\n", res[0].note.text,) 68 | 69 | def test_note_settings_overriden_by_note_body(self) -> None: 70 | val = "TableGroup name [note: 'Settings note'] \n{\ntable1\nnote: 'Body note'\ntable2\n}" 71 | res = table_group.parse_string(val, parseAll=True) 72 | self.assertEqual(res[0].name, "name") 73 | self.assertEqual(res[0].items, ["table1", "table2"]) 74 | self.assertEqual(res[0].note.text, "Body note") 75 | -------------------------------------------------------------------------------- /pydbml/_classes/column.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | from typing import Optional 3 | from typing import TYPE_CHECKING 4 | from typing import Union 5 | 6 | from pydbml.exceptions import TableNotFoundError 7 | from .base import SQLObject, DBMLObject 8 | from .enum import Enum 9 | from .expression import Expression 10 | from .note import Note 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from .table import Table 14 | from .reference import Reference 15 | 16 | 17 | class Column(SQLObject, DBMLObject): 18 | '''Class representing table column.''' 19 | 20 | required_attributes = ('name', 'type') 21 | dont_compare_fields = ('table',) 22 | 23 | def __init__(self, 24 | name: str, 25 | type: Union[str, Enum], 26 | unique: bool = False, 27 | not_null: bool = False, 28 | pk: bool = False, 29 | autoinc: bool = False, 30 | default: Optional[Union[str, int, bool, float, Expression]] = None, 31 | note: Optional[Union[Note, str]] = None, 32 | comment: Optional[str] = None, 33 | properties: Union[Dict[str, str], None] = None 34 | ): 35 | self.name = name 36 | self.type = type 37 | self.unique = unique 38 | self.not_null = not_null 39 | self.pk = pk 40 | self.autoinc = autoinc 41 | self.comment = comment 42 | self.note = Note(note) 43 | self.properties = properties if properties else {} 44 | 45 | self.default = default 46 | self.table: Optional['Table'] = None 47 | 48 | def __eq__(self, other: object) -> bool: 49 | if other is self: 50 | return True 51 | if not isinstance(other, self.__class__): 52 | return False 53 | self_table = self.table.full_name if self.table else None 54 | other_table = other.table.full_name if other.table else None 55 | if self_table != other_table: 56 | return False 57 | return super().__eq__(other) 58 | 59 | @property 60 | def note(self): 61 | return self._note 62 | 63 | @note.setter 64 | def note(self, val: Note) -> None: 65 | self._note = val 66 | val.parent = self 67 | 68 | def get_refs(self) -> List['Reference']: 69 | ''' 70 | get all references related to this column (where this col is col1 in) 71 | ''' 72 | if not self.table: 73 | raise TableNotFoundError('Table for the column is not set') 74 | return [ref for ref in self.table.get_refs() if self in ref.col1] 75 | 76 | @property 77 | def database(self): 78 | return self.table.database if self.table else None 79 | 80 | def __repr__(self): 81 | ''' 82 | >>> Column('name', 'VARCHAR2') 83 | 84 | ''' 85 | type_name = self.type if isinstance(self.type, str) else self.type.name 86 | return f'' 87 | 88 | def __str__(self): 89 | ''' 90 | >>> print(Column('name', 'VARCHAR2')) 91 | name[VARCHAR2] 92 | ''' 93 | 94 | return f'{self.name}[{self.type}]' 95 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/reference.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from typing import List 3 | 4 | from pydbml.classes import Reference, Column 5 | from pydbml.constants import MANY_TO_MANY, MANY_TO_ONE, ONE_TO_ONE, ONE_TO_MANY 6 | from pydbml.exceptions import TableNotFoundError 7 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 8 | from pydbml.renderer.sql.default.utils import comment_to_sql, get_full_name_for_sql 9 | 10 | 11 | def col_names(cols: List[Column]) -> str: 12 | return ', '.join(f'"{c.name}"' for c in cols) 13 | 14 | 15 | def validate_for_sql(model: Reference): 16 | for col in chain(model.col1, model.col2): 17 | if col.table is None: 18 | raise TableNotFoundError(f'Table on {col} is not set') 19 | 20 | 21 | def generate_inline_sql(model: Reference, source_col: List[Column], ref_col: List[Column]) -> str: 22 | result = comment_to_sql(model.comment) if model.comment else '' 23 | result += ( 24 | f'{{c}}FOREIGN KEY ({col_names(source_col)}) ' # type: ignore 25 | f'REFERENCES {get_full_name_for_sql(ref_col[0].table)} ({col_names(ref_col)})' # type: ignore 26 | ) 27 | if model.on_update: 28 | result += f' ON UPDATE {model.on_update.upper()}' 29 | if model.on_delete: 30 | result += f' ON DELETE {model.on_delete.upper()}' 31 | return result 32 | 33 | 34 | def generate_not_inline_sql(model: Reference, source_col: List['Column'], ref_col: List['Column']): 35 | result = comment_to_sql(model.comment) if model.comment else '' 36 | result += ( 37 | f'ALTER TABLE {get_full_name_for_sql(source_col[0].table)}' # type: ignore 38 | f' ADD {{c}}FOREIGN KEY ({col_names(source_col)})' 39 | f' REFERENCES {get_full_name_for_sql(ref_col[0].table)} ({col_names(ref_col)})' # type: ignore 40 | ) 41 | if model.on_update: 42 | result += f' ON UPDATE {model.on_update.upper()}' 43 | if model.on_delete: 44 | result += f' ON DELETE {model.on_delete.upper()}' 45 | return result + ';' 46 | 47 | 48 | def generate_many_to_many_sql(model: Reference) -> str: 49 | join_table = model.join_table 50 | table_sql = join_table.sql # type: ignore 51 | 52 | n = len(model.col1) 53 | ref1_sql = generate_not_inline_sql(model, join_table.columns[:n], model.col1) # type: ignore 54 | ref2_sql = generate_not_inline_sql(model, join_table.columns[n:], model.col2) # type: ignore 55 | 56 | result = '\n\n'.join((table_sql, ref1_sql, ref2_sql)) 57 | return result.format(c='') 58 | 59 | 60 | @DefaultSQLRenderer.renderer_for(Reference) 61 | def render_reference(model: Reference) -> str: 62 | ''' 63 | Returns SQL of the reference: 64 | 65 | ALTER TABLE "orders" ADD FOREIGN KEY ("customer_id") REFERENCES "customers ("id"); 66 | 67 | ''' 68 | validate_for_sql(model) 69 | 70 | if model.type == MANY_TO_MANY: 71 | return generate_many_to_many_sql(model) 72 | 73 | result = '' 74 | func = generate_inline_sql if model.inline else generate_not_inline_sql 75 | if model.type in (MANY_TO_ONE, ONE_TO_ONE): 76 | result = func(model=model, source_col=model.col1, ref_col=model.col2) 77 | elif model.type == ONE_TO_MANY: 78 | result = func(model=model, source_col=model.col2, ref_col=model.col1) 79 | 80 | c = f'CONSTRAINT "{model.name}" ' if model.name else '' 81 | 82 | return result.format(c=c) 83 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_index.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from pydbml.classes import Index, Expression, Note 4 | from pydbml.renderer.dbml.default.index import render_subjects, render_options, render_index 5 | 6 | 7 | class TestRenderSubjects: 8 | @staticmethod 9 | def test_column(index1: Index) -> None: 10 | assert render_subjects(index1.subjects) == "name" 11 | 12 | @staticmethod 13 | def test_expression(index1: Index) -> None: 14 | index1.subjects = [Expression("SUM(amount)")] 15 | assert render_subjects(index1.subjects) == "`SUM(amount)`" 16 | 17 | @staticmethod 18 | def test_string(index1: Index) -> None: 19 | index1.subjects = ["name"] 20 | assert render_subjects(index1.subjects) == "name" 21 | 22 | @staticmethod 23 | def test_multiple(index1: Index) -> None: 24 | index1.subjects.append(Expression("SUM(amount)")) 25 | index1.subjects.append("name") 26 | assert render_subjects(index1.subjects) == "(name, `SUM(amount)`, name)" 27 | 28 | 29 | class TestRenderOptions: 30 | @staticmethod 31 | def test_name(index1: Index) -> None: 32 | index1.name = "index_name" 33 | assert render_options(index1) == " [name: 'index_name']" 34 | 35 | @staticmethod 36 | def test_pk(index1: Index) -> None: 37 | index1.pk = True 38 | assert render_options(index1) == " [pk]" 39 | 40 | @staticmethod 41 | def test_unique(index1: Index) -> None: 42 | index1.unique = True 43 | assert render_options(index1) == " [unique]" 44 | 45 | @staticmethod 46 | def test_type(index1: Index) -> None: 47 | index1.type = "hash" 48 | assert render_options(index1) == " [type: hash]" 49 | 50 | @staticmethod 51 | def test_note(index1: Index) -> None: 52 | index1.note = Note("note") 53 | with patch( 54 | "pydbml.renderer.dbml.default.index.note_option_to_dbml", 55 | Mock(return_value="note"), 56 | ): 57 | assert render_options(index1) == " [note]" 58 | 59 | @staticmethod 60 | def test_no_options(index1: Index) -> None: 61 | assert render_options(index1) == "" 62 | 63 | @staticmethod 64 | def test_all_options(index1: Index) -> None: 65 | index1.name = "index_name" 66 | index1.pk = True 67 | index1.unique = True 68 | index1.type = "hash" 69 | index1.note = Note("note") 70 | with patch( 71 | "pydbml.renderer.dbml.default.index.note_option_to_dbml", 72 | Mock(return_value="note"), 73 | ): 74 | assert ( 75 | render_options(index1) 76 | == " [name: 'index_name', pk, unique, type: hash, note]" 77 | ) 78 | 79 | 80 | def test_render_index(index1: Index) -> None: 81 | index1.comment = "Index comment" 82 | with patch( 83 | "pydbml.renderer.dbml.default.index.render_subjects", 84 | Mock(return_value="subjects "), 85 | ) as render_subjects_mock: 86 | with patch( 87 | "pydbml.renderer.dbml.default.index.render_options", 88 | Mock(return_value="options"), 89 | ) as render_options_mock: 90 | assert render_index(index1) == '// Index comment\nsubjects options' 91 | assert render_subjects_mock.called 92 | assert render_options_mock.called 93 | -------------------------------------------------------------------------------- /test/test_editing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pathlib import Path 4 | from unittest import TestCase 5 | 6 | from pyparsing import ParserElement 7 | 8 | from pydbml import PyDBML 9 | 10 | 11 | ParserElement.set_default_whitespace_chars(' \t\r') 12 | 13 | 14 | TEST_DATA_PATH = Path(os.path.abspath(__file__)).parent / 'test_data' 15 | 16 | 17 | class EditingTestCase(TestCase): 18 | def setUp(self): 19 | self.dbml = PyDBML(TEST_DATA_PATH / 'editing.dbml') 20 | 21 | 22 | class TestEditTable(EditingTestCase): 23 | def test_name(self) -> None: 24 | products = self.dbml['public.products'] 25 | products.name = 'changed_products' 26 | self.assertIn('CREATE TABLE "changed_products"', products.sql) 27 | self.assertIn('CREATE INDEX "product_status" ON "changed_products"', products.sql) 28 | self.assertIn('Table "changed_products"', products.dbml) 29 | 30 | ref = self.dbml.refs[0] 31 | self.assertIn('ALTER TABLE "changed_products"', ref.sql) 32 | self.assertIn('"changed_products"."merchant_id"', ref.dbml) 33 | 34 | index = products.indexes[0] 35 | self.assertIn('ON "changed_products"', index.sql) 36 | 37 | def test_alias(self) -> None: 38 | products = self.dbml['public.products'] 39 | products.alias = 'new_alias' 40 | 41 | self.assertIn('as "new_alias"', products.dbml) 42 | 43 | 44 | class TestColumn(EditingTestCase): 45 | def test_name(self) -> None: 46 | products = self.dbml['public.products'] 47 | col = products['name'] 48 | col.name = 'new_name' 49 | self.assertEqual(col.sql, '"new_name" varchar') 50 | self.assertEqual(col.dbml, '"new_name" varchar') 51 | self.assertIn('"new_name" varchar', products.sql) 52 | self.assertIn('"new_name" varchar', products.dbml) 53 | 54 | self.assertEqual(col, products[col.name]) 55 | 56 | def test_name_index(self) -> None: 57 | products = self.dbml['public.products'] 58 | col = products['status'] 59 | col.name = 'changed_status' 60 | self.assertIn('"changed_status"', products.indexes[0].sql) 61 | self.assertIn('changed_status', products.indexes[0].dbml) 62 | self.assertIn( 63 | 'CREATE INDEX "product_status" ON "products" ("merchant_id", "changed_status");', 64 | products.sql 65 | ) 66 | self.assertIn( 67 | "(merchant_id, changed_status) [name: 'product_status']", 68 | products.dbml 69 | ) 70 | 71 | def test_name_ref(self) -> None: 72 | products = self.dbml['public.products'] 73 | col = products['merchant_id'] 74 | col.name = 'changed_merchant_id' 75 | merchants = self.dbml['public.merchants'] 76 | table_ref = merchants.get_refs()[0] 77 | self.assertIn('FOREIGN KEY ("changed_merchant_id")', table_ref.sql) 78 | 79 | 80 | class TestEnum(EditingTestCase): 81 | def test_enum_name(self): 82 | products = self.dbml['public.products'] 83 | enum = self.dbml.enums[0] 84 | enum.name = 'changed product status' 85 | self.assertIn('CREATE TYPE "changed product status"', enum.sql) 86 | self.assertIn('Enum "changed product status"', enum.dbml) 87 | 88 | col = products['status'] 89 | self.assertEqual(col.sql, '"status" "changed product status"') 90 | self.assertEqual(col.dbml, '"status" "changed product status"') 91 | -------------------------------------------------------------------------------- /pydbml/definitions/index.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _ 4 | from .common import _c 5 | from .common import c 6 | from .common import note 7 | from .common import pk 8 | from .common import unique 9 | from .generic import expression_literal 10 | from .generic import name 11 | from .generic import string_literal 12 | from pydbml.parser.blueprints import ExpressionBlueprint 13 | from pydbml.parser.blueprints import IndexBlueprint 14 | 15 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 16 | 17 | index_type = pp.CaselessLiteral("type:").suppress() + _ - ( 18 | pp.CaselessLiteral("brin")('type') | 19 | pp.CaselessLiteral("btree")('type') | 20 | pp.CaselessLiteral("gin")('type') | 21 | pp.CaselessLiteral("gist")('type') | 22 | pp.CaselessLiteral("hash")('type') | 23 | pp.CaselessLiteral("spgist")('type') 24 | ) 25 | index_setting = _ + ( 26 | unique('unique') 27 | | index_type 28 | | pp.CaselessLiteral("name:") + _ - string_literal('name') 29 | | note('note') 30 | | pk('pk') 31 | ) + _ 32 | index_settings = ( 33 | '[' + index_setting + (',' - index_setting)[...] - ']' + c 34 | ) 35 | 36 | 37 | def parse_index_settings(s, lok, tok): 38 | ''' 39 | [type: btree, name: 'name', unique, note: 'note'] 40 | ''' 41 | result = {} 42 | if 'unique' in tok: 43 | result['unique'] = True 44 | if 'name' in tok: 45 | result['name'] = tok['name'] 46 | if 'pk' in tok: 47 | result['pk'] = True 48 | if 'type' in tok: 49 | result['type'] = tok['type'] 50 | if 'note' in tok: 51 | result['note'] = tok['note'] 52 | if 'comment' in tok: 53 | result['comment'] = tok['comment'][0] 54 | return result 55 | 56 | 57 | index_settings.set_parse_action(parse_index_settings) 58 | 59 | subject = name | expression_literal 60 | composite_index_syntax = ( 61 | pp.Suppress('(') 62 | + subject + ( 63 | pp.Suppress(',') 64 | + subject 65 | )[...] 66 | + pp.Suppress(')') 67 | )('subject') + c + index_settings('settings')[0, 1] 68 | 69 | single_index_syntax = subject('subject') + c + index_settings('settings')[0, 1] 70 | index = _c + (single_index_syntax ^ composite_index_syntax) + c 71 | 72 | indexes = ( 73 | pp.CaselessLiteral('indexes').suppress() + _ 74 | - pp.Suppress('{') 75 | - index[1, ...] + _ 76 | + pp.Suppress('}') 77 | ) 78 | 79 | 80 | def parse_index(s, lok, tok): 81 | ''' 82 | (id, country) [pk] // composite primary key 83 | or 84 | "created_at" 85 | or 86 | booking_date [ 87 | name: 'name', 88 | unique 89 | ] 90 | ''' 91 | init_dict = {} 92 | if isinstance(tok['subject'], (str, ExpressionBlueprint)): 93 | subjects = [tok['subject']] 94 | else: 95 | subjects = list(tok['subject']) 96 | 97 | init_dict['subject_names'] = subjects 98 | settings = tok.get('settings', {}) 99 | init_dict.update(settings) 100 | 101 | # comments after settings have priority 102 | if 'comment' in tok: 103 | init_dict['comment'] = tok['comment'][0] 104 | if 'comment' not in init_dict and 'comment_before' in tok: 105 | comment = '\n'.join(c[0] for c in tok['comment_before']) 106 | init_dict['comment'] = comment 107 | return IndexBlueprint(**init_dict) 108 | 109 | 110 | index.set_parse_action(parse_index) 111 | -------------------------------------------------------------------------------- /pydbml/renderer/sql/default/table.py: -------------------------------------------------------------------------------- 1 | from textwrap import indent 2 | from typing import List 3 | 4 | from pydbml.constants import MANY_TO_ONE, ONE_TO_ONE, ONE_TO_MANY 5 | from pydbml.classes import Table, Reference, Column 6 | from pydbml.exceptions import UnknownDatabaseError 7 | from pydbml.renderer.sql.default.note import prepare_text_for_sql 8 | from pydbml.renderer.sql.default.renderer import DefaultSQLRenderer 9 | from pydbml.renderer.sql.default.utils import comment_to_sql, get_full_name_for_sql 10 | 11 | 12 | def get_references_for_sql(model: Table) -> List[Reference]: 13 | """ 14 | Return all references in the database where this table is on the left side of SQL 15 | reference definition. 16 | """ 17 | if not model.database: 18 | raise UnknownDatabaseError(f'Database for the table {model} is not set') 19 | result = [] 20 | for ref in model.database.refs: 21 | if (ref.type in (MANY_TO_ONE, ONE_TO_ONE)) and\ 22 | (ref.table1 == model): 23 | result.append(ref) 24 | elif (ref.type == ONE_TO_MANY) and (ref.table2 == model): 25 | result.append(ref) 26 | return result 27 | 28 | 29 | def get_inline_references_for_sql(model: Table) -> List[Reference]: 30 | ''' 31 | Return inline references for this table sql definition 32 | ''' 33 | if model.abstract: 34 | return [] 35 | return [r for r in get_references_for_sql(model) if r.inline] 36 | 37 | 38 | def create_body(model: Table) -> str: 39 | body: List[str] = [] 40 | body.extend(indent(DefaultSQLRenderer.render(c), " ") for c in model.columns) 41 | body.extend(indent(DefaultSQLRenderer.render(i), " ") for i in model.indexes if i.pk) 42 | body.extend(indent(DefaultSQLRenderer.render(r), " ") for r in get_inline_references_for_sql(model)) 43 | 44 | if model._has_composite_pk(): 45 | body.append( 46 | " PRIMARY KEY (" 47 | + ', '.join(f'"{c.name}"' for c in model.columns if c.pk) 48 | + ')') 49 | 50 | return ',\n'.join(body) 51 | 52 | 53 | def create_components(model: Table) -> str: 54 | components = [comment_to_sql(model.comment)] if model.comment else [] 55 | components.append(f'CREATE TABLE {get_full_name_for_sql(model)} (') 56 | 57 | body = create_body(model) 58 | 59 | components.append(body) 60 | components.append(');') 61 | components.extend('\n' + DefaultSQLRenderer.render(i) for i in model.indexes if not i.pk) 62 | 63 | return '\n'.join(components) 64 | 65 | 66 | def render_column_notes(model: Table) -> str: 67 | result = '' 68 | for col in model.columns: 69 | if col.note: 70 | quoted_note = f"'{prepare_text_for_sql(col.note)}'" 71 | note_sql = f'COMMENT ON COLUMN "{model.name}"."{col.name}" IS {quoted_note};' 72 | result += f'\n\n{note_sql}' 73 | return result 74 | 75 | 76 | @DefaultSQLRenderer.renderer_for(Table) 77 | def render_table(model: Table) -> str: 78 | ''' 79 | Returns full SQL for table definition: 80 | 81 | CREATE TABLE "countries" ( 82 | "code" int PRIMARY KEY, 83 | "name" varchar, 84 | "continent_name" varchar 85 | ); 86 | 87 | Also returns indexes if they were defined: 88 | 89 | CREATE INDEX ON "products" ("id", "name"); 90 | ''' 91 | result = create_components(model) 92 | 93 | if model.note: 94 | result += f'\n\n{model.note.sql}' 95 | 96 | result += render_column_notes(model) 97 | return result 98 | -------------------------------------------------------------------------------- /test/test_definitions/test_common.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyparsing import ParseSyntaxException 4 | from pyparsing import ParserElement 5 | 6 | from pydbml.definitions.common import _c 7 | from pydbml.definitions.common import comment 8 | from pydbml.definitions.common import note 9 | from pydbml.definitions.common import note_object 10 | 11 | 12 | ParserElement.set_default_whitespace_chars(' \t\r') 13 | 14 | 15 | class TestComment(TestCase): 16 | def test_comment_endstring(self) -> None: 17 | val = '//test comment' 18 | res = comment.parse_string(val, parseAll=True) 19 | self.assertEqual(res[0], 'test comment') 20 | 21 | def test_comment_endline(self) -> None: 22 | val = '//test comment\n\n\n\n\n' 23 | res = comment.parse_string(val) 24 | self.assertEqual(res[0], 'test comment') 25 | 26 | def test_multiline_comment(self) -> None: 27 | val = '/*test comment*/' 28 | res = comment.parse_string(val) 29 | self.assertEqual(res[0], 'test comment') 30 | 31 | val2 = '/*\nline1\nline2\nline3\n*/' 32 | res2 = comment.parse_string(val2) 33 | self.assertEqual(res2[0], '\nline1\nline2\nline3\n') 34 | 35 | 36 | class Test_c(TestCase): 37 | def test_comment(self) -> None: 38 | val = '\n\n\n\n//comment line 1\n\n//comment line 2' 39 | res = _c.parse_string(val, parseAll=True) 40 | self.assertEqual(list(res), ['comment line 1', 'comment line 2']) 41 | 42 | 43 | class TestNote(TestCase): 44 | def test_single_quote(self) -> None: 45 | val = "note: 'test note'" 46 | res = note.parse_string(val, parseAll=True) 47 | self.assertEqual(res[0].text, 'test note') 48 | 49 | def test_double_quote(self) -> None: 50 | val = 'note: \n "test note"' 51 | res = note.parse_string(val, parseAll=True) 52 | self.assertEqual(res[0].text, 'test note') 53 | 54 | def test_multiline(self) -> None: 55 | val = "note: '''line1\nline2\nline3'''" 56 | res = note.parse_string(val, parseAll=True) 57 | self.assertEqual(res[0].text, 'line1\nline2\nline3') 58 | 59 | def test_unclosed_quote(self) -> None: 60 | val = 'note: "test note' 61 | with self.assertRaises(ParseSyntaxException): 62 | note.parse_string(val, parseAll=True) 63 | 64 | def test_not_allowed_multiline(self) -> None: 65 | val = "note: 'line1\nline2\nline3'" 66 | with self.assertRaises(ParseSyntaxException): 67 | note.parse_string(val, parseAll=True) 68 | 69 | 70 | class TestNoteObject(TestCase): 71 | def test_single_quote(self) -> None: 72 | val = "note {'test note'}" 73 | res = note_object.parse_string(val, parseAll=True) 74 | self.assertEqual(res[0].text, 'test note') 75 | 76 | def test_double_quote(self) -> None: 77 | val = 'note \n\n {\n\n"test note"\n\n}' 78 | res = note_object.parse_string(val, parseAll=True) 79 | self.assertEqual(res[0].text, 'test note') 80 | 81 | def test_multiline(self) -> None: 82 | val = "note\n{ '''line1\nline2\nline3'''}" 83 | res = note_object.parse_string(val, parseAll=True) 84 | self.assertEqual(res[0].text, 'line1\nline2\nline3') 85 | 86 | def test_unclosed_quote(self) -> None: 87 | val = 'note{ "test note}' 88 | with self.assertRaises(ParseSyntaxException): 89 | note_object.parse_string(val, parseAll=True) 90 | 91 | def test_not_allowed_multiline(self) -> None: 92 | val = "note { 'line1\nline2\nline3' }" 93 | with self.assertRaises(ParseSyntaxException): 94 | note_object.parse_string(val, parseAll=True) 95 | -------------------------------------------------------------------------------- /docs/creating_schema.md: -------------------------------------------------------------------------------- 1 | # Creating DBML schema 2 | 3 | You can use PyDBML not only for parsing DBML files, but also for creating schema from scratch in Python. 4 | 5 | ## Database object 6 | 7 | You always start by creating a Database object. It will connect all other entities of the database for us. 8 | 9 | ```python 10 | >>> from pydbml import Database 11 | >>> db = Database() 12 | 13 | ``` 14 | 15 | Now let's create a table and add it to the database. 16 | 17 | ```python 18 | >>> from pydbml.classes import Table 19 | >>> table1 = Table(name='products') 20 | >>> db.add(table1) 21 | 22 | 23 | ``` 24 | 25 | To add columns to the table, you have to use the `add_column` method of the Table object. 26 | 27 | ```python 28 | >>> from pydbml.classes import Column 29 | >>> col1 = Column(name='id', type='Integer', pk=True, autoinc=True) 30 | >>> table1.add_column(col1) 31 | >>> col2 = Column(name='product_name', type='Varchar', unique=True) 32 | >>> table1.add_column(col2) 33 | >>> col3 = Column(name='manufacturer_id', type='Integer') 34 | >>> table1.add_column(col3) 35 | 36 | ``` 37 | 38 | Index is also a part of a table, so you have to add it similarly, using `add_index` method: 39 | 40 | ```python 41 | >>> from pydbml.classes import Index 42 | >>> index1 = Index([col2], unique=True) 43 | >>> table1.add_index(index1) 44 | 45 | ``` 46 | 47 | The table's third column, `manufacturer_id` looks like it should be a foreign key. Let's create another table, called `manufacturers`, so that we could create a relation. 48 | 49 | ```python 50 | >>> table2 = Table( 51 | ... 'manufacturers', 52 | ... columns=[ 53 | ... Column('id', type='Integer', pk=True, autoinc=True), 54 | ... Column('manufacturer_name', type='Varchar'), 55 | ... Column('manufacturer_country', type='Varchar') 56 | ... ] 57 | ... ) 58 | >>> db.add(table2) 59 |
60 | 61 | ``` 62 | 63 | Now to the relation: 64 | 65 | ```python 66 | >>> from pydbml.classes import Reference 67 | >>> ref = Reference('>', table1['manufacturer_id'], table2['id']) 68 | >>> db.add(ref) 69 | ', ['manufacturer_id'], ['id']> 70 | 71 | ``` 72 | 73 | You noticed that we are calling the `add` method on the Database after creating each object. While objects can somewhat function without being added to a database, DBML/SQL generation and some other useful methods won't work properly. 74 | 75 | Now let's generate DBML code for our schema. This is done by just calling the `dbml` property of the Database object: 76 | 77 | ```python 78 | >>> print(db.dbml) 79 | Table "products" { 80 | "id" Integer [pk, increment] 81 | "product_name" Varchar [unique] 82 | "manufacturer_id" Integer 83 | 84 | indexes { 85 | product_name [unique] 86 | } 87 | } 88 | 89 | Table "manufacturers" { 90 | "id" Integer [pk, increment] 91 | "manufacturer_name" Varchar 92 | "manufacturer_country" Varchar 93 | } 94 | 95 | Ref { 96 | "products"."manufacturer_id" > "manufacturers"."id" 97 | } 98 | 99 | ``` 100 | 101 | We can generate SQL for the schema similarly, by calling the `sql` property: 102 | 103 | ```python 104 | >>> print(db.sql) 105 | CREATE TABLE "products" ( 106 | "id" Integer PRIMARY KEY AUTOINCREMENT, 107 | "product_name" Varchar UNIQUE, 108 | "manufacturer_id" Integer 109 | ); 110 | 111 | CREATE UNIQUE INDEX ON "products" ("product_name"); 112 | 113 | CREATE TABLE "manufacturers" ( 114 | "id" Integer PRIMARY KEY AUTOINCREMENT, 115 | "manufacturer_name" Varchar, 116 | "manufacturer_country" Varchar 117 | ); 118 | 119 | ALTER TABLE "products" ADD FOREIGN KEY ("manufacturer_id") REFERENCES "manufacturers" ("id"); 120 | 121 | ``` 122 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_table.py: -------------------------------------------------------------------------------- 1 | from pydbml import Database 2 | from pydbml.classes import Table, Index, Note 3 | from pydbml.renderer.dbml.default.table import ( 4 | get_full_name_for_dbml, 5 | render_header, 6 | render_indexes, 7 | render_table, 8 | ) 9 | 10 | 11 | class TestGetFullNameForDBML: 12 | @staticmethod 13 | def test_no_schema(table1: Table) -> None: 14 | table1.schema = "public" 15 | assert get_full_name_for_dbml(table1) == '"products"' 16 | 17 | @staticmethod 18 | def test_with_schema(table1: Table) -> None: 19 | table1.schema = "myschema" 20 | assert get_full_name_for_dbml(table1) == '"myschema"."products"' 21 | 22 | 23 | class TestRenderHeader: 24 | @staticmethod 25 | def test_simple(table1: Table) -> None: 26 | expected = 'Table "products" ' 27 | assert render_header(table1) == expected 28 | 29 | @staticmethod 30 | def test_alias(table1: Table) -> None: 31 | table1.alias = "p" 32 | expected = 'Table "products" as "p" ' 33 | assert render_header(table1) == expected 34 | 35 | @staticmethod 36 | def test_header_color(table1: Table) -> None: 37 | table1.header_color = "red" 38 | expected = 'Table "products" [headercolor: red] ' 39 | assert render_header(table1) == expected 40 | 41 | @staticmethod 42 | def test_all(table1: Table) -> None: 43 | table1.alias = "p" 44 | table1.header_color = "red" 45 | expected = 'Table "products" as "p" [headercolor: red] ' 46 | assert render_header(table1) == expected 47 | 48 | 49 | class TestRenderIndexes: 50 | @staticmethod 51 | def test_no_indexes(table1: Table) -> None: 52 | assert render_indexes(table1) == "" 53 | 54 | @staticmethod 55 | def test_one_index(index1: Index) -> None: 56 | assert render_indexes(index1.table) == "\n indexes {\n name\n }\n" 57 | 58 | 59 | class TestRenderTable: 60 | @staticmethod 61 | def test_simple(db: Database, table1: Table) -> None: 62 | db.add(table1) 63 | expected = 'Table "products" {\n "id" integer\n "name" varchar\n}' 64 | assert render_table(table1) == expected 65 | 66 | @staticmethod 67 | def test_note_and_comment(db: Database, table1: Table) -> None: 68 | table1.comment = "Table comment" 69 | table1.note = Note("Table note") 70 | db.add(table1) 71 | expected = ( 72 | "// Table comment\n" 73 | 'Table "products" {\n' 74 | ' "id" integer\n' 75 | ' "name" varchar\n' 76 | " Note {\n" 77 | " 'Table note'\n" 78 | " }\n" 79 | "}" 80 | ) 81 | assert render_table(table1) == expected 82 | 83 | @staticmethod 84 | def test_properties(db: Database, table1: Table) -> None: 85 | table1.properties = {"key": "value"} 86 | db.add(table1) 87 | db.allow_properties = True 88 | expected = ( 89 | 'Table "products" {\n' 90 | ' "id" integer\n' 91 | ' "name" varchar\n' 92 | "\n" 93 | " key: 'value'\n" 94 | "}" 95 | ) 96 | assert render_table(table1) == expected 97 | 98 | @staticmethod 99 | def test_properties_not_allowed(db: Database, table1: Table) -> None: 100 | table1.properties = {"key": "value"} 101 | db.add(table1) 102 | db.allow_properties = False 103 | expected = ( 104 | 'Table "products" {\n' 105 | ' "id" integer\n' 106 | ' "name" varchar\n' 107 | "}" 108 | ) 109 | assert render_table(table1) == expected 110 | -------------------------------------------------------------------------------- /test/test_definitions/test_enum.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pyparsing import ParseSyntaxException 4 | from pyparsing import ParserElement 5 | 6 | from pydbml.definitions.enum import enum 7 | from pydbml.definitions.enum import enum_item 8 | from pydbml.definitions.enum import enum_settings 9 | 10 | 11 | ParserElement.set_default_whitespace_chars(' \t\r') 12 | 13 | 14 | class TestEnumSettings(TestCase): 15 | def test_note(self) -> None: 16 | val = '[note: "note content"]' 17 | enum_settings.parse_string(val, parseAll=True) 18 | 19 | def test_wrong(self) -> None: 20 | val = '[wrong]' 21 | with self.assertRaises(ParseSyntaxException): 22 | enum_settings.parse_string(val, parseAll=True) 23 | 24 | 25 | class TestEnumItem(TestCase): 26 | def test_no_settings(self) -> None: 27 | val = 'student' 28 | res = enum_item.parse_string(val, parseAll=True) 29 | self.assertEqual(res[0].name, 'student') 30 | 31 | def test_settings(self) -> None: 32 | val = 'student [note: "our future, help us God"]' 33 | res = enum_item.parse_string(val, parseAll=True) 34 | self.assertEqual(res[0].name, 'student') 35 | self.assertEqual(res[0].note.text, 'our future, help us God') 36 | 37 | def test_comment_before(self) -> None: 38 | val = '//comment before\nstudent [note: "our future, help us God"]' 39 | res = enum_item.parse_string(val, parseAll=True) 40 | self.assertEqual(res[0].name, 'student') 41 | self.assertEqual(res[0].note.text, 'our future, help us God') 42 | self.assertEqual(res[0].comment, 'comment before') 43 | 44 | def test_comment_after(self) -> None: 45 | val = 'student [note: "our future, help us God"] //comment after' 46 | res = enum_item.parse_string(val, parseAll=True) 47 | self.assertEqual(res[0].name, 'student') 48 | self.assertEqual(res[0].note.text, 'our future, help us God') 49 | self.assertEqual(res[0].comment, 'comment after') 50 | 51 | def test_comment_both(self) -> None: 52 | val = '//comment before\nstudent [note: "our future, help us God"] //comment after' 53 | res = enum_item.parse_string(val, parseAll=True) 54 | self.assertEqual(res[0].name, 'student') 55 | self.assertEqual(res[0].note.text, 'our future, help us God') 56 | self.assertEqual(res[0].comment, 'comment after') 57 | 58 | 59 | class TestEnum(TestCase): 60 | def test_singe_item(self) -> None: 61 | val = 'enum members {\nstudent\n}' 62 | res = enum.parse_string(val, parseAll=True) 63 | self.assertEqual(len(res[0].items), 1) 64 | self.assertEqual(res[0].name, 'members') 65 | 66 | def test_several_items(self) -> None: 67 | val = 'enum members {janitor teacher\nstudent\nheadmaster\n}' 68 | res = enum.parse_string(val, parseAll=True) 69 | self.assertEqual(len(res[0].items), 4) 70 | self.assertEqual(res[0].name, 'members') 71 | 72 | def test_schema(self) -> None: 73 | val1 = 'enum members {janitor teacher\nstudent\nheadmaster\n}' 74 | res1 = enum.parse_string(val1, parseAll=True) 75 | self.assertEqual(res1[0].schema, 'public') 76 | val2 = 'enum myschema.members {janitor teacher\nstudent\nheadmaster\n}' 77 | res2 = enum.parse_string(val2, parseAll=True) 78 | self.assertEqual(res2[0].schema, 'myschema') 79 | 80 | def test_comment(self) -> None: 81 | val = '//comment before\nenum members {janitor teacher\nstudent\nheadmaster\n}' 82 | res = enum.parse_string(val, parseAll=True) 83 | self.assertEqual(len(res[0].items), 4) 84 | self.assertEqual(res[0].name, 'members') 85 | self.assertEqual(res[0].comment, 'comment before') 86 | 87 | def test_oneline(self) -> None: 88 | val = 'enum members {student}' 89 | with self.assertRaises(ParseSyntaxException): 90 | enum.parse_string(val, parseAll=True) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/pypi/v/pydbml.svg)](https://pypi.org/project/pydbml/) [![](https://img.shields.io/pypi/dm/pydbml.svg)](https://pypi.org/project/pydbml/) [![](https://img.shields.io/github/v/tag/Vanderhoof/PyDBML.svg?label=GitHub)](https://github.com/Vanderhoof/PyDBML) ![](coverage.svg) 2 | 3 | # DBML parser for Python 4 | 5 | *Compliant with DBML **v3.9.5** syntax* 6 | 7 | PyDBML is a Python parser and builder for [DBML](https://www.dbml.org) syntax. 8 | 9 | > The project was rewritten in May 2022, the new version 1.0.0 is not compatible with versions 0.x.x. See details in [Upgrading to PyDBML 1.0.0](docs/upgrading.md). 10 | 11 | **Docs:** 12 | 13 | * [Class Reference](docs/classes.md) 14 | * [Creating DBML schema](docs/creating_schema.md) 15 | * [Upgrading to PyDBML 1.0.0](docs/upgrading.md) 16 | * [Arbitrary Properties](docs/properties.md) 17 | 18 | > PyDBML requires Python v3.8 or higher 19 | 20 | ## Installation 21 | 22 | You can install PyDBML using pip: 23 | 24 | ```bash 25 | pip3 install pydbml 26 | ``` 27 | 28 | ## Quick start 29 | 30 | To parse a DBML file, import the `PyDBML` class and initialize it with Path object 31 | 32 | ```python 33 | >>> from pydbml import PyDBML 34 | >>> from pathlib import Path 35 | >>> parsed = PyDBML(Path('test_schema.dbml')) 36 | 37 | ``` 38 | 39 | or with file stream 40 | 41 | ```python 42 | >>> with open('test_schema.dbml') as f: 43 | ... parsed = PyDBML(f) 44 | 45 | ``` 46 | 47 | or with entire source string 48 | 49 | ```python 50 | >>> with open('test_schema.dbml') as f: 51 | ... source = f.read() 52 | >>> parsed = PyDBML(source) 53 | >>> parsed 54 | 55 | 56 | ``` 57 | 58 | The parser returns a Database object that is a container for the parsed DBML entities. 59 | 60 | You can access tables inside the `tables` attribute: 61 | 62 | ```python 63 | >>> for table in parsed.tables: 64 | ... print(table.name) 65 | ... 66 | orders 67 | order_items 68 | products 69 | users 70 | merchants 71 | countries 72 | 73 | ``` 74 | 75 | Or just by getting items by index or full table name: 76 | 77 | ```python 78 | >>> parsed[1] 79 |
80 | >>> parsed['public.countries'] 81 |
82 | 83 | ``` 84 | 85 | Other attributes are: 86 | 87 | * **refs** — list of all references, 88 | * **enums** — list of all enums, 89 | * **table_groups** — list of all table groups, 90 | * **project** — the Project object, if was defined. 91 | 92 | Generate SQL for your DBML Database by accessing the `sql` property: 93 | 94 | ```python 95 | >>> print(parsed.sql) # doctest:+ELLIPSIS 96 | CREATE TYPE "orders_status" AS ENUM ( 97 | 'created', 98 | 'running', 99 | 'done', 100 | 'failure' 101 | ); 102 | 103 | CREATE TYPE "product status" AS ENUM ( 104 | 'Out of Stock', 105 | 'In Stock' 106 | ); 107 | 108 | CREATE TABLE "orders" ( 109 | "id" int PRIMARY KEY AUTOINCREMENT, 110 | "user_id" int UNIQUE NOT NULL, 111 | "status" "orders_status", 112 | "created_at" varchar 113 | ); 114 | ... 115 | 116 | ``` 117 | 118 | Generate DBML for your Database by accessing the `dbml` property: 119 | 120 | ```python 121 | >>> parsed.project.items['author'] = 'John Doe' 122 | >>> print(parsed.dbml) # doctest:+ELLIPSIS 123 | Project "test_schema" { 124 | author: 'John Doe' 125 | Note { 126 | 'This schema is used for PyDBML doctest' 127 | } 128 | } 129 | 130 | Enum "orders_status" { 131 | "created" 132 | "running" 133 | "done" 134 | "failure" 135 | } 136 | 137 | Enum "product status" { 138 | "Out of Stock" 139 | "In Stock" 140 | } 141 | 142 | Table "orders" [headercolor: #fff] { 143 | "id" int [pk, increment] 144 | "user_id" int [unique, not null] 145 | "status" "orders_status" 146 | "created_at" varchar 147 | } 148 | 149 | Table "order_items" { 150 | "order_id" int 151 | "product_id" int 152 | "quantity" int [default: 1] 153 | } 154 | ... 155 | 156 | ``` 157 | -------------------------------------------------------------------------------- /pydbml/definitions/table.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from pydbml.parser.blueprints import TableBlueprint 4 | from .column import table_column, table_column_with_properties 5 | from .common import _, hex_color 6 | from .common import _c 7 | from .common import end 8 | from .common import note 9 | from .common import note_object 10 | from .generic import name, string_literal 11 | from .index import indexes 12 | 13 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 14 | 15 | alias = pp.WordStart() + pp.Literal('as').suppress() - pp.WordEnd() - name 16 | 17 | 18 | header_color = ( 19 | pp.CaselessLiteral('headercolor:').suppress() + _ 20 | - pp.Combine(hex_color)('header_color') 21 | ) 22 | table_setting = _ + (note('note') | header_color) + _ 23 | table_settings = '[' + table_setting + (',' + table_setting)[...] + ']' 24 | 25 | 26 | def parse_table_settings(s, loc, tok): 27 | ''' 28 | [headercolor: #cccccc, note: 'note'] 29 | ''' 30 | result = {} 31 | if 'note' in tok: 32 | result['note'] = tok['note'] 33 | if 'header_color' in tok: 34 | result['header_color'] = tok['header_color'] 35 | return result 36 | 37 | 38 | table_settings.set_parse_action(parse_table_settings) 39 | 40 | 41 | note_element = note | note_object 42 | 43 | prop = name + pp.Suppress(":") + string_literal 44 | 45 | table_element = _ + ( 46 | table_column.set_results_name('columns', list_all_matches=True) | 47 | note_element('note') | 48 | indexes.set_results_name('indexes', list_all_matches=True) 49 | ) + _ 50 | table_element_with_property = _ + ( 51 | table_column_with_properties.set_results_name('columns', list_all_matches=True) | 52 | note_element('note') | 53 | indexes.set_results_name('indexes', list_all_matches=True) | 54 | prop.set_results_name('property', list_all_matches=True) 55 | ) + _ 56 | 57 | table_body = table_element[...] 58 | table_body_with_properties = table_element_with_property[...] 59 | 60 | table_name = (name('schema') + '.' + name('name')) | (name('name')) 61 | 62 | table = _c + ( 63 | pp.CaselessLiteral("table").suppress() 64 | + table_name 65 | + alias('alias')[0, 1] 66 | + table_settings('settings')[0, 1] + _ 67 | + '{' - table_body + _ + '}' 68 | ) + end 69 | 70 | table_with_properties = _c + ( 71 | pp.CaselessLiteral("table").suppress() 72 | + table_name 73 | + alias('alias')[0, 1] 74 | + table_settings('settings')[0, 1] + _ 75 | + '{' - table_body_with_properties + _ + '}' 76 | ) + end 77 | 78 | 79 | def parse_table(s, loc, tok): 80 | ''' 81 | Table bookings as bb [headercolor: #cccccc] { 82 | id integer 83 | country varchar [NOT NULL, ref: > countries.country_name] 84 | booking_date date unique pk 85 | created_at timestamp 86 | 87 | indexes { 88 | (id, country) [pk] // composite primary key 89 | } 90 | } 91 | ''' 92 | init_dict = { 93 | 'name': tok['name'], 94 | } 95 | if 'schema' in tok: 96 | init_dict['schema'] = tok['schema'] 97 | if 'settings' in tok: 98 | init_dict.update(tok['settings']) 99 | if 'alias' in tok: 100 | init_dict['alias'] = tok['alias'][0] 101 | if 'note' in tok: 102 | # will override one from settings 103 | init_dict['note'] = tok['note'][0] 104 | if 'indexes' in tok: 105 | init_dict['indexes'] = tok['indexes'][0] 106 | if 'columns' in tok: 107 | init_dict['columns'] = tok['columns'] 108 | if 'comment_before' in tok: 109 | comment = '\n'.join(c[0] for c in tok['comment_before']) 110 | init_dict['comment'] = comment 111 | if 'property' in tok: 112 | init_dict['properties'] = {k: v for k, v in tok['property']} 113 | 114 | if not init_dict.get('columns'): 115 | raise SyntaxError(f'Table {init_dict["name"]} at position {loc} has no columns!') 116 | 117 | result = TableBlueprint(**init_dict) 118 | 119 | return result 120 | 121 | 122 | table.set_parse_action(parse_table) 123 | table_with_properties.set_parse_action(parse_table) 124 | -------------------------------------------------------------------------------- /test/test_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from pathlib import Path 4 | from unittest import TestCase 5 | 6 | from pydbml import PyDBML 7 | from pydbml.classes import Column 8 | from pydbml.classes import Enum 9 | from pydbml.classes import EnumItem 10 | from pydbml.classes import Expression 11 | from pydbml.classes import Index 12 | from pydbml.classes import Note 13 | from pydbml.classes import Project 14 | from pydbml.classes import Reference 15 | from pydbml.classes import Table 16 | from pydbml.classes import TableGroup 17 | from pydbml.database import Database 18 | 19 | 20 | TEST_DATA_PATH = Path(os.path.abspath(__file__)).parent / 'test_data' 21 | 22 | 23 | class TestGenerateDBML(TestCase): 24 | def create_database(self) -> Database: 25 | database = Database() 26 | emp_level = Enum( 27 | 'level', 28 | [ 29 | EnumItem('junior'), 30 | EnumItem('middle'), 31 | EnumItem('senior'), 32 | ] 33 | ) 34 | database.add(emp_level) 35 | 36 | t1 = Table('Employees', alias='emp') 37 | c11 = Column('id', 'integer', pk=True, autoinc=True) 38 | c12 = Column('name', 'varchar', note=Note('Full employee name')) 39 | c13 = Column('age', 'number', default=0) 40 | c14 = Column('level', 'level') 41 | c15 = Column('favorite_book_id', 'integer') 42 | t1.add_column(c11) 43 | t1.add_column(c12) 44 | t1.add_column(c13) 45 | t1.add_column(c14) 46 | t1.add_column(c15) 47 | database.add(t1) 48 | 49 | t2 = Table('books') 50 | c21 = Column('id', 'integer', pk=True, autoinc=True) 51 | c22 = Column('title', 'varchar') 52 | c23 = Column('author', 'varchar') 53 | c24 = Column('country_id', 'integer') 54 | t2.add_column(c21) 55 | t2.add_column(c22) 56 | t2.add_column(c23) 57 | t2.add_column(c24) 58 | database.add(t2) 59 | 60 | t3 = Table('countries') 61 | c31 = Column('id', 'integer', pk=True, autoinc=True) 62 | c32 = Column('name', 'varchar2', unique=True) 63 | t3.add_column(c31) 64 | t3.add_column(c32) 65 | i31 = Index([c32], unique=True) 66 | t3.add_index(i31) 67 | i32 = Index([Expression('UPPER(name)')]) 68 | t3.add_index(i32) 69 | database.add(t3) 70 | 71 | ref1 = Reference('>', c15, c21) 72 | database.add(ref1) 73 | 74 | ref2 = Reference('<', c31, c24, name='Country Reference', inline=True) 75 | database.add(ref2) 76 | 77 | tg = TableGroup('Unanimate', [t2, t3]) 78 | database.add(tg) 79 | 80 | p = Project('my project', {'author': 'me', 'reason': 'testing'}) 81 | database.add(p) 82 | return database 83 | 84 | def test_generate_dbml(self) -> None: 85 | database = self.create_database() 86 | with open(TEST_DATA_PATH / 'integration1.dbml') as f: 87 | expected = f.read() 88 | self.assertEqual(database.dbml, expected) 89 | 90 | def test_generate_sql(self) -> None: 91 | database = self.create_database() 92 | with open(TEST_DATA_PATH / 'integration1.sql') as f: 93 | expected = f.read() 94 | self.assertEqual(database.sql, expected) 95 | 96 | def test_parser(self): 97 | source_path = TEST_DATA_PATH / 'integration1.dbml' 98 | with self.assertRaises(TypeError): 99 | PyDBML(2) 100 | res1 = PyDBML(source_path) 101 | self.assertIsInstance(res1, Database) 102 | with open(source_path) as f: 103 | res2 = PyDBML(f) 104 | self.assertIsInstance(res2, Database) 105 | with open(source_path) as f: 106 | source = f.read() 107 | res3 = PyDBML(source) 108 | self.assertIsInstance(res3, Database) 109 | res4 = PyDBML('\ufeff' + source) 110 | self.assertIsInstance(res4, Database) 111 | 112 | pydbml = PyDBML() 113 | self.assertIsInstance(pydbml, PyDBML) 114 | res5 = pydbml.parse(source) 115 | self.assertIsInstance(res5, Database) 116 | res6 = PyDBML.parse('\ufeff' + source) 117 | self.assertIsInstance(res6, Database) 118 | res7 = PyDBML.parse_file(str(source_path)) 119 | self.assertIsInstance(res7, Database) 120 | with open(source_path) as f: 121 | res8 = PyDBML.parse_file(f) 122 | self.assertIsInstance(res8, Database) 123 | -------------------------------------------------------------------------------- /test/test_classes/test_reference.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from pydbml.classes import Column 4 | from pydbml.classes import Reference 5 | from pydbml.classes import Table 6 | from pydbml.exceptions import DBMLError 7 | from pydbml.exceptions import TableNotFoundError 8 | from pydbml.renderer.sql.default.reference import validate_for_sql 9 | 10 | 11 | class TestReference(TestCase): 12 | def test_table1(self): 13 | t = Table('products') 14 | c1 = Column('name', 'varchar2') 15 | t2 = Table('names') 16 | c2 = Column('name_val', 'varchar2') 17 | t2.add_column(c2) 18 | ref = Reference('>', c1, c2) 19 | self.assertIsNone(ref.table1) 20 | t.add_column(c1) 21 | self.assertIs(ref.table1, t) 22 | 23 | def test_join_table(self) -> None: 24 | t1 = Table('books') 25 | c11 = Column('id', 'integer', pk=True) 26 | c12 = Column('author', 'varchar') 27 | t1.add_column(c11) 28 | t1.add_column(c12) 29 | t2 = Table('authors') 30 | c21 = Column('id', 'integer', pk=True) 31 | c22 = Column('name', 'varchar') 32 | t2.add_column(c21) 33 | t2.add_column(c22) 34 | ref0 = Reference('>', [c11], [c21]) 35 | ref = Reference('<>', [c11, c12], [c21, c22]) 36 | 37 | self.assertIsNone(ref0.join_table) 38 | self.assertEqual(ref.join_table.name, 'books_authors') 39 | self.assertEqual(len(ref.join_table.columns), 4) 40 | 41 | def test_join_table_none(self) -> None: 42 | t1 = Table('books') 43 | c11 = Column('id', 'integer', pk=True) 44 | c12 = Column('author', 'varchar') 45 | t1.add_column(c11) 46 | t1.add_column(c12) 47 | t2 = Table('authors') 48 | c21 = Column('id', 'integer', pk=True) 49 | c22 = Column('name', 'varchar') 50 | t2.add_column(c21) 51 | t2.add_column(c22) 52 | ref = Reference('<>', [c11], [c21]) 53 | 54 | _table1 = ref.table1 55 | ref.col1[0].table = None 56 | with self.assertRaises(TableNotFoundError): 57 | ref.join_table 58 | 59 | ref.col1[0].table = _table1 60 | ref.col2[0].table = None 61 | with self.assertRaises(TableNotFoundError): 62 | ref.join_table 63 | 64 | 65 | class TestReferenceInline(TestCase): 66 | def test_validate_different_tables(self): 67 | t1 = Table('products') 68 | c11 = Column('id', 'integer') 69 | c12 = Column('name', 'varchar2') 70 | t1.add_column(c11) 71 | t1.add_column(c12) 72 | t2 = Table('names') 73 | c21 = Column('name_val', 'varchar2') 74 | t2.add_column(c21) 75 | ref = Reference( 76 | '<', 77 | [c12, c21], 78 | [c21], 79 | name='nameref', 80 | comment='Reference comment\nmultiline', 81 | on_update='CASCADE', 82 | on_delete='SET NULL', 83 | inline=True 84 | ) 85 | with self.assertRaises(DBMLError): 86 | ref._validate() 87 | 88 | ref = Reference( 89 | '<', 90 | [c11, c12], 91 | [c21, c12], 92 | name='nameref', 93 | comment='Reference comment\nmultiline', 94 | on_update='CASCADE', 95 | on_delete='SET NULL', 96 | inline=True 97 | ) 98 | with self.assertRaises(DBMLError): 99 | ref._validate() 100 | 101 | def test_validate_no_table(self): 102 | c1 = Column('id', 'integer') 103 | c2 = Column('name', 'varchar2') 104 | c3 = Column('age', 'number') 105 | c4 = Column('active', 'boolean') 106 | ref1 = Reference( 107 | '<', 108 | c1, 109 | c2 110 | ) 111 | with self.assertRaises(TableNotFoundError): 112 | validate_for_sql(ref1) 113 | table = Table('name') 114 | table.add_column(c1) 115 | with self.assertRaises(TableNotFoundError): 116 | validate_for_sql(ref1) 117 | table.delete_column(c1) 118 | 119 | ref2 = Reference( 120 | '<', 121 | [c1, c2], 122 | [c3, c4] 123 | ) 124 | with self.assertRaises(TableNotFoundError): 125 | validate_for_sql(ref2) 126 | table = Table('name') 127 | table.add_column(c1) 128 | table.add_column(c2) 129 | with self.assertRaises(TableNotFoundError): 130 | validate_for_sql(ref2) 131 | -------------------------------------------------------------------------------- /test/test_renderer/test_dbml/test_reference.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from pydbml._classes.reference import Reference 6 | from pydbml.exceptions import TableNotFoundError, DBMLError 7 | from pydbml.renderer.dbml.default.reference import ( 8 | validate_for_dbml, 9 | render_inline_reference, 10 | render_col, 11 | render_options, 12 | render_not_inline_reference, 13 | render_reference, 14 | ) 15 | 16 | 17 | class TestValidateFroDBML: 18 | @staticmethod 19 | def test_ok(reference1: Reference) -> None: 20 | validate_for_dbml(reference1) 21 | 22 | @staticmethod 23 | def test_no_table(reference1: Reference) -> None: 24 | reference1.col2[0].table = None 25 | with pytest.raises(TableNotFoundError): 26 | validate_for_dbml(reference1) 27 | 28 | 29 | class TestRenderInlineReference: 30 | @staticmethod 31 | def test_ok(reference1: Reference) -> None: 32 | reference1.inline = True 33 | assert render_inline_reference(reference1) == 'ref: > "products"."id"' 34 | 35 | @staticmethod 36 | def test_composite(reference1: Reference) -> None: 37 | reference1.col2.append(reference1.col2[0]) 38 | with pytest.raises(DBMLError): 39 | render_inline_reference(reference1) 40 | 41 | 42 | class TestRendeCol: 43 | @staticmethod 44 | def test_single(reference1: Reference) -> None: 45 | assert render_col(reference1.col2) == '"id"' 46 | 47 | @staticmethod 48 | def test_multiple(reference1: Reference) -> None: 49 | reference1.col2.append(reference1.col2[0]) 50 | assert render_col(reference1.col2) == '("id", "id")' 51 | 52 | 53 | class TestRenderOptions: 54 | @staticmethod 55 | def test_on_update(reference1: Reference) -> None: 56 | reference1.on_update = "cascade" 57 | assert render_options(reference1) == " [update: cascade]" 58 | 59 | @staticmethod 60 | def test_on_delete(reference1: Reference) -> None: 61 | reference1.on_delete = "set null" 62 | assert render_options(reference1) == " [delete: set null]" 63 | 64 | @staticmethod 65 | def test_both(reference1: Reference) -> None: 66 | reference1.on_update = "cascade" 67 | reference1.on_delete = "set null" 68 | assert render_options(reference1) == " [update: cascade, delete: set null]" 69 | 70 | @staticmethod 71 | def test_no_options(reference1: Reference) -> None: 72 | assert render_options(reference1) == "" 73 | 74 | 75 | class TestRenderNotInlineReference: 76 | @staticmethod 77 | def test_ok(reference1: Reference) -> None: 78 | assert render_not_inline_reference(reference1) == ( 79 | 'Ref {\n "orders"."product_id" > "products"."id"\n}' 80 | ) 81 | 82 | @staticmethod 83 | def test_comment(reference1: Reference) -> None: 84 | reference1.comment = "comment" 85 | assert render_not_inline_reference(reference1) == ( 86 | '// comment\nRef {\n "orders"."product_id" > "products"."id"\n}' 87 | ) 88 | 89 | @staticmethod 90 | def test_name(reference1: Reference) -> None: 91 | reference1.name = "ref_name" 92 | assert render_not_inline_reference(reference1) == ( 93 | 'Ref ref_name {\n "orders"."product_id" > "products"."id"\n}' 94 | ) 95 | 96 | 97 | class TestRenderReference: 98 | @staticmethod 99 | def test_inline(reference1: Reference) -> None: 100 | reference1.inline = True 101 | with patch( 102 | "pydbml.renderer.dbml.default.reference.render_inline_reference", 103 | return_value="inline", 104 | ) as mock_render: 105 | with patch( 106 | "pydbml.renderer.dbml.default.reference.validate_for_dbml", 107 | ) as mock_validate: 108 | assert render_reference(reference1) == "inline" 109 | assert mock_render.called 110 | assert mock_validate.called 111 | 112 | @staticmethod 113 | def test_not_inline(reference1: Reference) -> None: 114 | with patch( 115 | "pydbml.renderer.dbml.default.reference.render_not_inline_reference", 116 | return_value="not inline", 117 | ) as mock_render: 118 | with patch( 119 | "pydbml.renderer.dbml.default.reference.validate_for_dbml", 120 | ) as mock_validate: 121 | assert render_reference(reference1) == "not inline" 122 | assert mock_render.called 123 | assert mock_validate.called 124 | -------------------------------------------------------------------------------- /test/test_tools.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import pytest 4 | 5 | from pydbml.classes import Note 6 | from pydbml.tools import remove_indentation, doublequote_string 7 | from pydbml.renderer.sql.default.utils import comment_to_sql 8 | from pydbml.tools import indent 9 | from pydbml.renderer.dbml.default.utils import note_option_to_dbml, comment_to_dbml 10 | from pydbml.tools import strip_empty_lines 11 | 12 | 13 | class TestCommentToDBML(TestCase): 14 | def test_comment(self) -> None: 15 | oneline = "comment" 16 | self.assertEqual(f"// {oneline}\n", comment_to_dbml(oneline)) 17 | 18 | expected = """// 19 | // line1 20 | // line2 21 | // line3 22 | // 23 | """ 24 | source = "\nline1\nline2\nline3\n" 25 | self.assertEqual(comment_to_dbml(source), expected) 26 | 27 | 28 | class TestCommentToSQL(TestCase): 29 | def test_comment(self) -> None: 30 | oneline = "comment" 31 | self.assertEqual(f"-- {oneline}\n", comment_to_sql(oneline)) 32 | 33 | expected = """-- 34 | -- line1 35 | -- line2 36 | -- line3 37 | -- 38 | """ 39 | source = "\nline1\nline2\nline3\n" 40 | self.assertEqual(comment_to_sql(source), expected) 41 | 42 | 43 | class TestNoteOptionToDBML(TestCase): 44 | def test_oneline(self) -> None: 45 | note = Note("one line note") 46 | self.assertEqual(f"note: 'one line note'", note_option_to_dbml(note)) 47 | 48 | def test_oneline_with_quote(self) -> None: 49 | note = Note("one line'd note") 50 | self.assertEqual(f"note: 'one line\\'d note'", note_option_to_dbml(note)) 51 | 52 | def test_multiline(self) -> None: 53 | note = Note("line1\nline2\nline3") 54 | expected = "note: '''line1\nline2\nline3'''" 55 | self.assertEqual(expected, note_option_to_dbml(note)) 56 | 57 | def test_multiline_with_quotes(self) -> None: 58 | note = Note("line1\n'''line2\nline3") 59 | expected = "note: '''line1\n\\'''line2\nline3'''" 60 | self.assertEqual(expected, note_option_to_dbml(note)) 61 | 62 | 63 | class TestIndent(TestCase): 64 | def test_empty(self) -> None: 65 | self.assertEqual(indent(""), "") 66 | 67 | def test_nonempty(self) -> None: 68 | oneline = "one line text" 69 | self.assertEqual(indent(oneline), f" {oneline}") 70 | source = "line1\nline2\nline3" 71 | expected = " line1\n line2\n line3" 72 | self.assertEqual(indent(source), expected) 73 | expected2 = " line1\n line2\n line3" 74 | self.assertEqual(indent(source, 2), expected2) 75 | 76 | 77 | class TestStripEmptyLines(TestCase): 78 | def test_empty(self) -> None: 79 | source = "" 80 | self.assertEqual(strip_empty_lines(source), source) 81 | 82 | def test_no_empty_lines(self) -> None: 83 | source = "line1\n\n\nline2" 84 | self.assertEqual(strip_empty_lines(source), source) 85 | 86 | def test_empty_lines(self) -> None: 87 | stripped = " line1\n\n line2" 88 | source = f"\n \n \n\t \t \n \n{stripped}\n\n\n \n \t \n\t \n \n" 89 | self.assertEqual(strip_empty_lines(source), stripped) 90 | 91 | def test_one_empty_line(self) -> None: 92 | stripped = " line1\n\n line2" 93 | source = f"\n{stripped}" 94 | self.assertEqual(strip_empty_lines(source), stripped) 95 | source = f"{stripped}\n" 96 | self.assertEqual(strip_empty_lines(source), stripped) 97 | 98 | def test_end(self) -> None: 99 | stripped = " line1\n\n line2" 100 | source = f"\n{stripped}\n " 101 | self.assertEqual(strip_empty_lines(source), stripped) 102 | 103 | 104 | class TestRemoveIndentation(TestCase): 105 | def test_empty(self) -> None: 106 | source = "" 107 | self.assertEqual(remove_indentation(source), source) 108 | 109 | def test_not_empty(self) -> None: 110 | source = " line1\n line2" 111 | expected = "line1\n line2" 112 | self.assertEqual(remove_indentation(source), expected) 113 | 114 | 115 | class TestDoublequoteString: 116 | @staticmethod 117 | @pytest.mark.parametrize( 118 | "source,expected", 119 | [ 120 | ("Test string", '"Test string"'), 121 | ('String with "quotes"!', '"String with \\"quotes\\"!"'), 122 | ('"Quoted string"', '"Quoted string"'), 123 | ], 124 | ) 125 | def test_oneline(source: str, expected: str) -> None: 126 | assert doublequote_string(source) == expected 127 | 128 | @staticmethod 129 | def test_multiline() -> None: 130 | with pytest.raises(ValueError): 131 | doublequote_string('line1\nline2') 132 | -------------------------------------------------------------------------------- /pydbml/_classes/reference.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from typing import Collection 3 | from typing import Literal 4 | from typing import Optional 5 | from typing import Union 6 | 7 | from pydbml.constants import MANY_TO_MANY 8 | from pydbml.exceptions import DBMLError 9 | from pydbml.exceptions import TableNotFoundError 10 | from .base import SQLObject, DBMLObject 11 | from .column import Column 12 | from .table import Table 13 | 14 | 15 | class Reference(SQLObject, DBMLObject): 16 | ''' 17 | Class, representing a foreign key constraint. 18 | It is a separate object, which is not connected to Table or Column objects 19 | and its `sql` property contains the ALTER TABLE clause. 20 | ''' 21 | required_attributes = ('type', 'col1', 'col2') 22 | dont_compare_fields = ('database', '_inline') 23 | 24 | def __init__(self, 25 | type: Literal['>', '<', '-', '<>'], 26 | col1: Union[Column, Collection[Column]], 27 | col2: Union[Column, Collection[Column]], 28 | name: Optional[str] = None, 29 | comment: Optional[str] = None, 30 | on_update: Optional[str] = None, 31 | on_delete: Optional[str] = None, 32 | inline: bool = False): 33 | self.database = None 34 | self.type = type 35 | self.col1 = [col1] if isinstance(col1, Column) else list(col1) 36 | self.col2 = [col2] if isinstance(col2, Column) else list(col2) 37 | self.name = name if name else None 38 | self.comment = comment 39 | self.on_update = on_update 40 | self.on_delete = on_delete 41 | self._inline = inline 42 | 43 | @property 44 | def inline(self) -> bool: 45 | return self._inline and not self.type == MANY_TO_MANY 46 | 47 | @inline.setter 48 | def inline(self, val) -> None: 49 | self._inline = val 50 | 51 | @property 52 | def join_table(self) -> Optional[Table]: 53 | if self.type != MANY_TO_MANY: 54 | return None 55 | 56 | if self.table1 is None: 57 | raise TableNotFoundError(f"Cannot generate join table for {self}: table 1 is unknown") 58 | if self.table2 is None: 59 | raise TableNotFoundError(f"Cannot generate join table for {self}: table 2 is unknown") 60 | 61 | return Table( 62 | name=f'{self.table1.name}_{self.table2.name}', 63 | schema=self.table1.schema, 64 | columns=( 65 | Column(name=f'{c.table.name}_{c.name}', type=c.type, not_null=True, pk=True) # type: ignore 66 | for c in chain(self.col1, self.col2) 67 | ), 68 | abstract=True 69 | ) 70 | 71 | @property 72 | def table1(self) -> Optional[Table]: 73 | self._validate() 74 | return self.col1[0].table if self.col1 else None 75 | 76 | @property 77 | def table2(self) -> Optional[Table]: 78 | self._validate() 79 | return self.col2[0].table if self.col2 else None 80 | 81 | def __repr__(self): 82 | ''' 83 | >>> c1 = Column('c1', 'int') 84 | >>> c2 = Column('c2', 'int') 85 | >>> Reference('>', col1=c1, col2=c2) 86 | ', ['c1'], ['c2']> 87 | >>> c12 = Column('c12', 'int') 88 | >>> c22 = Column('c22', 'int') 89 | >>> Reference('<', col1=[c1, c12], col2=(c2, c22)) 90 | 91 | ''' 92 | 93 | col1 = ', '.join(f'{c.name!r}' for c in self.col1) 94 | col2 = ', '.join(f'{c.name!r}' for c in self.col2) 95 | return f"" 96 | 97 | def __str__(self): 98 | ''' 99 | >>> c1 = Column('c1', 'int') 100 | >>> c2 = Column('c2', 'int') 101 | >>> print(Reference('>', col1=c1, col2=c2)) 102 | Reference([c1] > [c2] 103 | >>> c12 = Column('c12', 'int') 104 | >>> c22 = Column('c22', 'int') 105 | >>> print(Reference('<', col1=[c1, c12], col2=(c2, c22))) 106 | Reference([c1, c12] < [c2, c22] 107 | ''' 108 | 109 | col1 = ', '.join(f'{c.name}' for c in self.col1) 110 | col2 = ', '.join(f'{c.name}' for c in self.col2) 111 | return f"Reference([{col1}] {self.type} [{col2}]" 112 | 113 | def _validate(self): 114 | table1 = self.col1[0].table 115 | if any(c.table != table1 for c in self.col1): 116 | raise DBMLError('Columns in col1 are from different tables') 117 | 118 | table2 = self.col2[0].table 119 | if any(c.table != table2 for c in self.col2): 120 | raise DBMLError('Columns in col2 are from different tables') 121 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | 5 | from pydbml import Database 6 | from pydbml._classes.reference import Reference 7 | from pydbml._classes.sticky_note import StickyNote 8 | from pydbml.classes import Column, Enum, EnumItem, Note, Expression, Table, Index 9 | 10 | 11 | @pytest.fixture 12 | def db(): 13 | return Database() 14 | 15 | 16 | @pytest.fixture 17 | def enum_item1(): 18 | return EnumItem('en-US') 19 | 20 | 21 | @pytest.fixture 22 | def enum1(): 23 | return Enum('product status', ('production', 'development')) 24 | 25 | 26 | @pytest.fixture 27 | def expression1() -> Expression: 28 | return Expression('SUM(amount)') 29 | 30 | 31 | @pytest.fixture 32 | def simple_column() -> Column: 33 | return Column( 34 | name='id', 35 | type='integer' 36 | ) 37 | 38 | 39 | @pytest.fixture 40 | def simple_column_with_table(db: Database, table1: Table, simple_column: Column) -> Column: 41 | table1.add_column(simple_column) 42 | db.add(table1) 43 | return simple_column 44 | 45 | 46 | @pytest.fixture 47 | def complex_column(enum1: Enum) -> Column: 48 | return Column( 49 | name='counter', 50 | type=enum1, 51 | pk=True, 52 | autoinc=True, 53 | unique=True, 54 | not_null=True, 55 | default=0, 56 | comment='This is a counter column', 57 | note=Note('This is a note for the column'), 58 | properties={'foo': 'bar', 'baz': "qux\nqux"} 59 | ) 60 | 61 | 62 | @pytest.fixture 63 | def complex_column_with_table(db: Database, table1: Table, complex_column: Column) -> Column: 64 | table1.add_column(complex_column) 65 | db.add(table1) 66 | return complex_column 67 | 68 | @pytest.fixture 69 | def string_column() -> Column: 70 | return Column( 71 | name='name', 72 | type='varchar(255)', 73 | pk=False, 74 | autoinc=False, 75 | unique=True, 76 | not_null=True, 77 | default='value\'s', 78 | comment='This is a defaulted string column', 79 | note=Note('This is a note for the column'), 80 | properties={'foo': 'bar', 'baz': "qux\nqux"} 81 | ) 82 | 83 | 84 | @pytest.fixture 85 | def string_column_with_table(db: Database, table1: Table, string_column: Column) -> Column: 86 | table1.add_column(string_column) 87 | db.add(table1) 88 | return string_column 89 | 90 | @pytest.fixture 91 | def boolean_column() -> Column: 92 | return Column( 93 | name='enabled', 94 | type='boolean', 95 | pk=False, 96 | autoinc=False, 97 | unique=False, 98 | not_null=True, 99 | default=False, 100 | comment='This is a defaulted boolean column', 101 | note=Note('This is a note for the column'), 102 | properties={'foo': 'bar', 'baz': "qux\nqux"} 103 | ) 104 | 105 | 106 | @pytest.fixture 107 | def boolean_column_with_table(db: Database, table1: Table, boolean_column: Column) -> Column: 108 | table1.add_column(boolean_column) 109 | db.add(table1) 110 | return boolean_column 111 | 112 | 113 | @pytest.fixture 114 | def table1() -> Table: 115 | return Table( 116 | name='products', 117 | columns=[ 118 | Column('id', 'integer'), 119 | Column('name', 'varchar'), 120 | ] 121 | ) 122 | 123 | 124 | @pytest.fixture 125 | def table2() -> Table: 126 | return Table( 127 | name='products', 128 | columns=[ 129 | Column('id', 'integer'), 130 | Column('name', 'varchar'), 131 | ] 132 | ) 133 | 134 | 135 | @pytest.fixture 136 | def table3() -> Table: 137 | return Table( 138 | name='orders', 139 | columns=[ 140 | Column('id', 'integer'), 141 | Column('product_id', 'integer'), 142 | Column('price', 'float'), 143 | ] 144 | ) 145 | 146 | @pytest.fixture 147 | def reference1(table2: Table, table3: Table) -> Reference: 148 | return Reference( 149 | type='>', 150 | col1=[table3.columns[1]], 151 | col2=[table2.columns[0]], 152 | ) 153 | 154 | 155 | @pytest.fixture 156 | def index1(table1: Table) -> Index: 157 | result = Index( 158 | subjects=[table1.columns[1]] 159 | ) 160 | table1.add_index(result) 161 | return result 162 | 163 | 164 | @pytest.fixture 165 | def note1(): 166 | return Note('Simple note') 167 | 168 | 169 | @pytest.fixture 170 | def sticky_note1(): 171 | return StickyNote(name='mynote', text='Simple note') 172 | 173 | 174 | @pytest.fixture 175 | def multiline_note(): 176 | return Note( 177 | dedent( 178 | '''\ 179 | This is a multiline note. 180 | It has multiple lines.''' 181 | ) 182 | ) 183 | -------------------------------------------------------------------------------- /pydbml/definitions/column.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _ 4 | from .common import _c 5 | from .common import c 6 | from .common import n 7 | from .common import note 8 | from .common import pk 9 | from .common import unique 10 | from .generic import boolean_literal 11 | from .generic import expression 12 | from .generic import expression_literal 13 | from .generic import name 14 | from .generic import number_literal 15 | from .generic import string_literal 16 | from .reference import ref_inline 17 | from pydbml.parser.blueprints import ColumnBlueprint 18 | 19 | 20 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 21 | 22 | type_args = ("(" + pp.original_text_for(expression) + ")") 23 | 24 | # column type is parsed as a single string, it will be split by blueprint 25 | column_type = pp.Combine((name + pp.Literal('[]')) | (name + '.' + name) | ((name) + type_args[0, 1])) 26 | 27 | default = pp.CaselessLiteral('default:').suppress() + _ - ( 28 | string_literal 29 | | expression_literal 30 | | boolean_literal.set_parse_action( 31 | lambda s, loc, tok: { 32 | 'true': True, 33 | 'false': False, 34 | 'NULL': None 35 | }[tok[0]] 36 | ) 37 | | number_literal.set_parse_action( 38 | lambda s, loc, tok: float(''.join(tok[0])) if '.' in tok[0] else int(tok[0]) 39 | ) 40 | ) 41 | 42 | prop = name + pp.Suppress(":") + string_literal 43 | 44 | column_setting = _ + ( 45 | pp.CaselessLiteral("not null").set_parse_action( 46 | lambda s, loc, tok: True 47 | )('notnull') 48 | | pp.CaselessLiteral("null").set_parse_action( 49 | lambda s, loc, tok: False 50 | )('notnull') 51 | | pp.CaselessLiteral("primary key")('pk') 52 | | pk('pk') 53 | | unique('unique') 54 | | pp.CaselessLiteral("increment")('increment') 55 | | note('note') 56 | | ref_inline('ref*') 57 | | default('default') 58 | ) + _ 59 | 60 | column_setting_with_property = column_setting | prop.set_results_name('property', list_all_matches=True) 61 | 62 | column_settings = '[' - column_setting + ("," + column_setting)[...] + ']' + c 63 | 64 | column_settings_with_properties = '[' - (_ + column_setting_with_property + _) + ("," + column_setting_with_property)[...] + ']' + c 65 | 66 | 67 | def parse_column_settings(s, loc, tok): 68 | ''' 69 | [ NOT NULL, increment, default: `now()`] 70 | ''' 71 | result = {} 72 | if tok.get('notnull'): 73 | result['not_null'] = True 74 | if 'pk' in tok: 75 | result['pk'] = True 76 | if 'unique' in tok: 77 | result['unique'] = True 78 | if 'increment' in tok: 79 | result['autoinc'] = True 80 | if 'note' in tok: 81 | result['note'] = tok['note'] 82 | if 'default' in tok: 83 | result['default'] = tok['default'][0] 84 | if 'ref' in tok: 85 | result['ref_blueprints'] = list(tok['ref']) 86 | if 'comment' in tok: 87 | result['comment'] = tok['comment'][0] 88 | if 'property' in tok: 89 | result['properties'] = {k: v for k, v in tok['property']} 90 | return result 91 | 92 | 93 | column_settings.set_parse_action(parse_column_settings) 94 | column_settings_with_properties.set_parse_action(parse_column_settings) 95 | 96 | 97 | constraint = pp.CaselessLiteral("unique") | pp.CaselessLiteral("pk") 98 | 99 | table_column = _c + ( 100 | name('name') 101 | + column_type('type') 102 | + constraint[...]('constraints') + c 103 | + column_settings('settings')[0, 1] 104 | ) + n 105 | 106 | 107 | table_column_with_properties = _c + ( 108 | name('name') 109 | + column_type('type') 110 | + constraint[...]('constraints') + c 111 | + column_settings_with_properties('settings')[0, 1] 112 | ) + n 113 | 114 | 115 | def parse_column(s, loc, tok): 116 | ''' 117 | address varchar(255) [unique, not null, note: 'to include unit number'] 118 | ''' 119 | init_dict = { 120 | 'name': tok['name'], 121 | 'type': tok['type'], 122 | } 123 | # deprecated 124 | for constraint in tok.get('constraints', []): 125 | if constraint == 'pk': 126 | init_dict['pk'] = True 127 | elif constraint == 'unique': 128 | init_dict['unique'] = True 129 | 130 | if 'settings' in tok: 131 | init_dict.update(tok['settings']) 132 | 133 | # comments after column definition have priority 134 | if 'comment' in tok: 135 | init_dict['comment'] = tok['comment'][0] 136 | if 'comment' not in init_dict and 'comment_before' in tok: 137 | comment = '\n'.join(c[0] for c in tok['comment_before']) 138 | init_dict['comment'] = comment 139 | 140 | return ColumnBlueprint(**init_dict) 141 | 142 | 143 | table_column.set_parse_action(parse_column) 144 | table_column_with_properties.set_parse_action(parse_column) 145 | -------------------------------------------------------------------------------- /pydbml/definitions/reference.py: -------------------------------------------------------------------------------- 1 | import pyparsing as pp 2 | 3 | from .common import _ 4 | from .common import _c 5 | from .common import c 6 | from .common import n 7 | from .generic import name 8 | from pydbml.parser.blueprints import ReferenceBlueprint 9 | 10 | pp.ParserElement.set_default_whitespace_chars(' \t\r') 11 | 12 | relation = pp.oneOf("> - < <>") 13 | 14 | col_name = ( 15 | ( 16 | name('schema') + '.' + name('table') + '.' - name('field') 17 | ) | ( 18 | name('table') + '.' + name('field') 19 | ) 20 | ) 21 | 22 | ref_inline = pp.Literal("ref:") - relation('type') - col_name 23 | 24 | 25 | def parse_inline_relation(s, loc, tok): 26 | ''' 27 | ref: < table.column 28 | or 29 | ref: < schema1.table.column 30 | ''' 31 | result = { 32 | 'type': tok['type'], 33 | 'inline': True, 34 | 'table2': tok['table'], 35 | 'col2': tok['field'] 36 | } 37 | if 'schema' in tok: 38 | result['schema2'] = tok['schema'] 39 | return ReferenceBlueprint(**result) 40 | 41 | 42 | ref_inline.set_parse_action(parse_inline_relation) 43 | 44 | on_option = ( 45 | pp.CaselessLiteral('no action') 46 | | pp.CaselessLiteral('restrict') 47 | | pp.CaselessLiteral('cascade') 48 | | pp.CaselessLiteral('set null') 49 | | pp.CaselessLiteral('set default') 50 | ) 51 | update = pp.CaselessLiteral("update:").suppress() + _ + on_option 52 | delete = pp.CaselessLiteral("delete:").suppress() + _ + on_option 53 | 54 | ref_setting = _ + (update('update') | delete('delete')) + _ 55 | 56 | ref_settings = ( 57 | '[' 58 | + ref_setting 59 | + ( 60 | ',' 61 | + ref_setting 62 | )[...] 63 | + ']' + c 64 | ) 65 | 66 | 67 | def parse_ref_settings(s, loc, tok): 68 | ''' 69 | [delete: cascade] 70 | ''' 71 | result = {} 72 | if 'update' in tok: 73 | result['on_update'] = tok['update'][0] 74 | if 'delete' in tok: 75 | result['on_delete'] = tok['delete'][0] 76 | if 'comment' in tok: 77 | result['comment'] = tok['comment'][0] 78 | return result 79 | 80 | 81 | ref_settings.set_parse_action(parse_ref_settings) 82 | 83 | composite_name = ( 84 | '(' + pp.White()[...] 85 | - name + pp.White()[...] 86 | + ( 87 | pp.White()[...] + "," 88 | + pp.White()[...] + name 89 | + pp.White()[...] 90 | )[...] 91 | + ')' 92 | ) 93 | name_or_composite = name | pp.Combine(composite_name) 94 | 95 | ref_cols = ( 96 | ( 97 | name('schema') 98 | + pp.Suppress('.') + name('table') 99 | + pp.Suppress('.') + name_or_composite('field') 100 | ) | ( 101 | name('table') 102 | + pp.Suppress('.') + name_or_composite('field') 103 | ) 104 | ) 105 | 106 | 107 | def parse_ref_cols(s, loc, tok): 108 | ''' 109 | table1.col1 110 | or 111 | schema1.table1.col1 112 | or 113 | schema1.table1.(col1, col2) 114 | ''' 115 | result = { 116 | 'table': tok['table'], 117 | 'field': tok['field'], 118 | } 119 | if 'schema' in tok: 120 | result['schema'] = tok['schema'] 121 | return result 122 | 123 | 124 | ref_cols.set_parse_action(parse_ref_cols) 125 | 126 | ref_body = ( 127 | ref_cols('col1') 128 | - relation('type') 129 | - ref_cols('col2') + c 130 | + ref_settings('settings')[0, 1] 131 | ) 132 | # ref_body = ( 133 | # table_name('table1') 134 | # - '.' 135 | # - name_or_composite('field1') 136 | # - relation('type') 137 | # - table_name('table2') 138 | # - '.' 139 | # - name_or_composite('field2') + c 140 | # + ref_settings('settings')[0, 1] 141 | # ) 142 | 143 | 144 | ref_short = _c + pp.CaselessLiteral('ref') + name('name')[0, 1] + ':' - ref_body 145 | ref_long = _c + ( 146 | pp.CaselessLiteral('ref') + _ 147 | + name('name')[0, 1] + _ 148 | + '{' + _ 149 | - ref_body + _ 150 | - '}' 151 | ) 152 | 153 | 154 | def parse_ref(s, loc, tok): 155 | ''' 156 | ref name: table1.col1 > table2.col2 157 | or 158 | ref name { 159 | table1.col1 < table2.col2 160 | } 161 | ''' 162 | init_dict = { 163 | 'type': tok['type'], 164 | 'inline': False, 165 | 'table1': tok['col1']['table'], 166 | 'col1': tok['col1']['field'], 167 | 'table2': tok['col2']['table'], 168 | 'col2': tok['col2']['field'], 169 | } 170 | 171 | if 'schema' in tok['col1']: 172 | init_dict['schema1'] = tok['col1']['schema'] 173 | if 'schema' in tok['col2']: 174 | init_dict['schema2'] = tok['col2']['schema'] 175 | if 'name' in tok: 176 | init_dict['name'] = tok['name'] 177 | if 'settings' in tok: 178 | init_dict.update(tok['settings']) 179 | 180 | # comments after settings have priority 181 | if 'comment' in tok: 182 | init_dict['comment'] = tok['comment'][0] 183 | if 'comment' not in init_dict and 'comment_before' in tok: 184 | comment = '\n'.join(c[0] for c in tok['comment_before']) 185 | init_dict['comment'] = comment 186 | 187 | ref = ReferenceBlueprint(**init_dict) 188 | return ref 189 | 190 | 191 | ref_short.set_parse_action(parse_ref) 192 | ref_long.set_parse_action(parse_ref) 193 | 194 | ref = ref_short | ref_long + (n | pp.StringEnd()) 195 | --------------------------------------------------------------------------------