From d276e464a4161ac84c7ba160a544f230b2d6a0e2 Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sat, 16 Mar 2024 18:53:55 +0100 Subject: [PATCH 1/6] feat: allow arrays in column type (v3.1.0) --- README.md | 2 +- pydbml/definitions/column.py | 2 +- test/test_definitions/test_column.py | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1fe4415..bec1991 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Enum "product status" { "In Stock" } -Table "orders" { +Table "orders" [headercolor: #fff] { "id" int [pk, increment] "user_id" int [unique, not null] "status" "orders_status" diff --git a/pydbml/definitions/column.py b/pydbml/definitions/column.py index a9fe0c1..5c5e4b3 100644 --- a/pydbml/definitions/column.py +++ b/pydbml/definitions/column.py @@ -22,7 +22,7 @@ type_args = ("(" + pp.original_text_for(expression) + ")") # column type is parsed as a single string, it will be split by blueprint -column_type = pp.Combine((name + '.' + name) | ((name) + type_args[0, 1])) +column_type = pp.Combine((name + pp.Literal('[]')) | (name + '.' + name) | ((name) + type_args[0, 1])) default = pp.CaselessLiteral('default:').suppress() + _ - ( string_literal diff --git a/test/test_definitions/test_column.py b/test/test_definitions/test_column.py index 262699b..b8b6ea1 100644 --- a/test/test_definitions/test_column.py +++ b/test/test_definitions/test_column.py @@ -37,6 +37,11 @@ def test_expression(self) -> None: res = column_type.parse_string(val, parseAll=True) self.assertEqual(res[0], val) + def test_array(self) -> None: + val = 'int[]' + res = column_type.parse_string(val, parseAll=True) + self.assertEqual(res[0], val) + def test_symbols(self) -> None: val = '(*#^)' with self.assertRaises(ParseException): From a21d32b23b42f71fed5a5ab724f38d6fbb4fcfaa Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sat, 16 Mar 2024 19:28:02 +0100 Subject: [PATCH 2/6] feat: allow double quotes in expression (v3.1.2) --- pydbml/definitions/generic.py | 4 ++-- test/test_definitions/test_generic.py | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pydbml/definitions/generic.py b/pydbml/definitions/generic.py index b29962f..69c1270 100644 --- a/pydbml/definitions/generic.py +++ b/pydbml/definitions/generic.py @@ -33,8 +33,8 @@ # Expression -expr_chars = pp.Word(pp.alphanums + "'`,._+- \n\t") -expr_chars_no_comma_space = pp.Word(pp.alphanums + "'`._+-") +expr_chars = pp.Word(pp.alphanums + "\"'`,._+- \n\t") +expr_chars_no_comma_space = pp.Word(pp.alphanums + "\"'`._+-") expression = pp.Forward() factor = ( pp.Word(pp.alphanums + '_')[0, 1] + '(' + expression + ')' diff --git a/test/test_definitions/test_generic.py b/test/test_definitions/test_generic.py index 25afe6e..9a78430 100644 --- a/test/test_definitions/test_generic.py +++ b/test/test_definitions/test_generic.py @@ -2,7 +2,7 @@ from pyparsing import ParserElement -from pydbml.definitions.generic import expression_literal +from pydbml.definitions.generic import expression_literal, expression from pydbml.parser.blueprints import ExpressionBlueprint @@ -15,3 +15,10 @@ def test_expression_literal(self) -> None: res = expression_literal.parse_string(val) self.assertIsInstance(res[0], ExpressionBlueprint) self.assertEqual(res[0].text, 'SUM(amount)') + +class TestExpression(TestCase): + def test_comma_separated_expression(self) -> None: + val = 'MAX, 3, "MAX", \'MAX\'' + expected = ['MAX', ',', '3', ',', '"MAX"', ',', "'MAX'"] + res = expression.parse_string(val, parseAll=True) + self.assertEqual(res.asList(), expected) From a6a4e8490effa2a7a78f156b17c7fde33bc4d332 Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sun, 17 Mar 2024 07:43:01 +0100 Subject: [PATCH 3/6] feat: fix equality check, don't allow duplicate tables in tablegroup (v3.1.6) --- pydbml/classes/base.py | 15 +++++++++++++-- pydbml/classes/note.py | 4 ++-- pydbml/database.py | 8 ++++---- pydbml/exceptions.py | 4 ++++ pydbml/parser/blueprints.py | 6 +++++- test/test_blueprints/test_table_group.py | 18 ++++++++++++++++++ 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/pydbml/classes/base.py b/pydbml/classes/base.py index 4a1b844..07c4330 100644 --- a/pydbml/classes/base.py +++ b/pydbml/classes/base.py @@ -33,6 +33,17 @@ def __eq__(self, other: object) -> bool: attributes are equal. """ - if isinstance(other, self.__class__): - return self.__dict__ == other.__dict__ + if not isinstance(other, self.__class__): + return False + # not comparing those because they are circular references + not_compared_fields = ('parent', 'table', 'database') + + self_dict = dict(self.__dict__) + other_dict = dict(other.__dict__) + + for field in not_compared_fields: + self_dict.pop(field, None) + other_dict.pop(field, None) + + return self_dict == other_dict return False diff --git a/pydbml/classes/note.py b/pydbml/classes/note.py index 2482b5a..a3965eb 100644 --- a/pydbml/classes/note.py +++ b/pydbml/classes/note.py @@ -1,5 +1,5 @@ import re -from typing import Any +from typing import Any, Union from .base import SQLObject from pydbml.tools import indent @@ -8,7 +8,7 @@ class Note(SQLObject): - def __init__(self, text: Any): + def __init__(self, text: Union[str, 'Note']) -> None: self.text: str if isinstance(text, Note): self.text = text.text diff --git a/pydbml/database.py b/pydbml/database.py index 910b6c5..f2e8a35 100644 --- a/pydbml/database.py +++ b/pydbml/database.py @@ -83,7 +83,7 @@ def add(self, obj: Any) -> Any: raise DatabaseValidationError(f'Unsupported type {type(obj)}.') def add_table(self, obj: Table) -> Table: - if obj in self.tables: + if obj.database == self and obj in self.tables: raise DatabaseValidationError(f'{obj} is already in the database.') if obj.full_name in self.table_dict: raise DatabaseValidationError(f'Table {obj.full_name} is already in the database.') @@ -107,7 +107,7 @@ def add_reference(self, obj: Reference): 'Cannot add reference. At least one of the referenced tables' ' should belong to this database' ) - if obj in self.refs: + if obj.database == self and obj in self.refs: raise DatabaseValidationError(f'{obj} is already in the database.') self._set_database(obj) @@ -115,7 +115,7 @@ def add_reference(self, obj: Reference): return obj def add_enum(self, obj: Enum) -> Enum: - if obj in self.enums: + if obj.database == self and obj in self.enums: raise DatabaseValidationError(f'{obj} is already in the database.') for enum in self.enums: if enum.name == obj.name and enum.schema == obj.schema: @@ -126,7 +126,7 @@ def add_enum(self, obj: Enum) -> Enum: return obj def add_table_group(self, obj: TableGroup) -> TableGroup: - if obj in self.table_groups: + if obj.database == self and obj in self.table_groups: raise DatabaseValidationError(f'{obj} is already in the database.') for table_group in self.table_groups: if table_group.name == obj.name: diff --git a/pydbml/exceptions.py b/pydbml/exceptions.py index 757c434..b5914f7 100644 --- a/pydbml/exceptions.py +++ b/pydbml/exceptions.py @@ -28,3 +28,7 @@ class DBMLError(Exception): class DatabaseValidationError(Exception): pass + + +class ValidationError(Exception): + pass diff --git a/pydbml/parser/blueprints.py b/pydbml/parser/blueprints.py index f052977..a4e0ecc 100644 --- a/pydbml/parser/blueprints.py +++ b/pydbml/parser/blueprints.py @@ -19,6 +19,7 @@ from pydbml.classes import TableGroup from pydbml.exceptions import ColumnNotFoundError from pydbml.exceptions import TableNotFoundError +from pydbml.exceptions import ValidationError from pydbml.tools import remove_indentation from pydbml.tools import strip_empty_lines @@ -280,7 +281,10 @@ def build(self) -> 'TableGroup': for table_name in self.items: components = table_name.split('.') schema, table = components if len(components) == 2 else ('public', components[0]) - items.append(self.parser.locate_table(schema, table)) + table_obj = self.parser.locate_table(schema, table) + if table_obj in items: + raise ValidationError(f'Table "{table}" is already in group "{self.name}"') + items.append(table_obj) return TableGroup( name=self.name, items=items, diff --git a/test/test_blueprints/test_table_group.py b/test/test_blueprints/test_table_group.py index 48a262f..763ebf8 100644 --- a/test/test_blueprints/test_table_group.py +++ b/test/test_blueprints/test_table_group.py @@ -3,6 +3,7 @@ from pydbml.classes import Table from pydbml.classes import TableGroup +from pydbml.exceptions import ValidationError from pydbml.parser.blueprints import TableGroupBlueprint @@ -51,3 +52,20 @@ def test_build_with_schema(self) -> None: self.assertEqual(locate_table_calls[1].args, ('myschema', 'table2')) for i in result.items: self.assertIsInstance(i, Table) + + def test_duplicate_table(self) -> None: + bp = TableGroupBlueprint( + name='TestTableGroup', + items=['table1', 'table2', 'table1'], + comment='Comment text' + ) + + parserMock = Mock() + parserMock.locate_table.side_effect = [ + Table(name='table1'), + Table(name='table2'), + Table(name='table1') + ] + bp.parser = parserMock + with self.assertRaises(ValidationError): + bp.build() From 048b3480e198da933c696aec41bab49ce97e0568 Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sun, 17 Mar 2024 08:02:24 +0100 Subject: [PATCH 4/6] feat: fix equality check again, don't allow duplicate refs (v3.1.6) --- pydbml/classes/base.py | 5 ++--- pydbml/classes/column.py | 8 ++++++++ pydbml/classes/index.py | 1 + pydbml/classes/note.py | 1 + pydbml/classes/project.py | 2 ++ pydbml/classes/reference.py | 1 + pydbml/classes/table.py | 1 + pydbml/classes/table_group.py | 1 + pydbml/database.py | 8 ++++---- 9 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pydbml/classes/base.py b/pydbml/classes/base.py index 07c4330..ec8bfd9 100644 --- a/pydbml/classes/base.py +++ b/pydbml/classes/base.py @@ -9,6 +9,7 @@ class SQLObject: Base class for all SQL objects. ''' required_attributes: Tuple[str, ...] = () + dont_compare_fields = () def check_attributes_for_sql(self): ''' @@ -36,14 +37,12 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): return False # not comparing those because they are circular references - not_compared_fields = ('parent', 'table', 'database') self_dict = dict(self.__dict__) other_dict = dict(other.__dict__) - for field in not_compared_fields: + for field in self.dont_compare_fields: self_dict.pop(field, None) other_dict.pop(field, None) return self_dict == other_dict - return False diff --git a/pydbml/classes/column.py b/pydbml/classes/column.py index 4743581..d599c2a 100644 --- a/pydbml/classes/column.py +++ b/pydbml/classes/column.py @@ -21,6 +21,7 @@ class Column(SQLObject): '''Class representing table column.''' required_attributes = ('name', 'type') + dont_compare_fields = ('table',) def __init__(self, name: str, @@ -45,6 +46,13 @@ def __init__(self, self.default = default self.table: Optional['Table'] = None + def __eq__(self, other: 'Column') -> bool: + self_table = self.table.full_name if self.table else None + other_table = other.table.full_name if other.table else None + if self_table != other_table: + return False + return super().__eq__(other) + @property def note(self): return self._note diff --git a/pydbml/classes/index.py b/pydbml/classes/index.py index b51a4b4..a3d771a 100644 --- a/pydbml/classes/index.py +++ b/pydbml/classes/index.py @@ -19,6 +19,7 @@ class Index(SQLObject): '''Class representing index.''' required_attributes = ('subjects', 'table') + dont_compare_fields = ('table',) def __init__(self, subjects: List[Union[str, 'Column', 'Expression']], diff --git a/pydbml/classes/note.py b/pydbml/classes/note.py index a3965eb..eee65cf 100644 --- a/pydbml/classes/note.py +++ b/pydbml/classes/note.py @@ -7,6 +7,7 @@ class Note(SQLObject): + dont_compare_fields = ('parent',) def __init__(self, text: Union[str, 'Note']) -> None: self.text: str diff --git a/pydbml/classes/project.py b/pydbml/classes/project.py index 3133a85..6069fc8 100644 --- a/pydbml/classes/project.py +++ b/pydbml/classes/project.py @@ -8,6 +8,8 @@ class Project: + dont_compare_fields = ('database',) + def __init__(self, name: str, items: Optional[Dict[str, str]] = None, diff --git a/pydbml/classes/reference.py b/pydbml/classes/reference.py index f093016..cb37e3b 100644 --- a/pydbml/classes/reference.py +++ b/pydbml/classes/reference.py @@ -25,6 +25,7 @@ class Reference(SQLObject): and its `sql` property contains the ALTER TABLE clause. ''' required_attributes = ('type', 'col1', 'col2') + dont_compare_fields = ('database', '_inline') def __init__(self, type: Literal['>', '<', '-', '<>'], diff --git a/pydbml/classes/table.py b/pydbml/classes/table.py index 5022725..f493e9c 100644 --- a/pydbml/classes/table.py +++ b/pydbml/classes/table.py @@ -28,6 +28,7 @@ class Table(SQLObject): '''Class representing table.''' required_attributes = ('name', 'schema') + dont_compare_fields = ('database',) def __init__(self, name: str, diff --git a/pydbml/classes/table_group.py b/pydbml/classes/table_group.py index 3386fa0..1f38978 100644 --- a/pydbml/classes/table_group.py +++ b/pydbml/classes/table_group.py @@ -11,6 +11,7 @@ class TableGroup: but after parsing the whole document, PyDBMLParseResults class replaces them with references to actual tables. ''' + dont_compare_fields = ('database',) def __init__(self, name: str, diff --git a/pydbml/database.py b/pydbml/database.py index f2e8a35..910b6c5 100644 --- a/pydbml/database.py +++ b/pydbml/database.py @@ -83,7 +83,7 @@ def add(self, obj: Any) -> Any: raise DatabaseValidationError(f'Unsupported type {type(obj)}.') def add_table(self, obj: Table) -> Table: - if obj.database == self and obj in self.tables: + if obj in self.tables: raise DatabaseValidationError(f'{obj} is already in the database.') if obj.full_name in self.table_dict: raise DatabaseValidationError(f'Table {obj.full_name} is already in the database.') @@ -107,7 +107,7 @@ def add_reference(self, obj: Reference): 'Cannot add reference. At least one of the referenced tables' ' should belong to this database' ) - if obj.database == self and obj in self.refs: + if obj in self.refs: raise DatabaseValidationError(f'{obj} is already in the database.') self._set_database(obj) @@ -115,7 +115,7 @@ def add_reference(self, obj: Reference): return obj def add_enum(self, obj: Enum) -> Enum: - if obj.database == self and obj in self.enums: + if obj in self.enums: raise DatabaseValidationError(f'{obj} is already in the database.') for enum in self.enums: if enum.name == obj.name and enum.schema == obj.schema: @@ -126,7 +126,7 @@ def add_enum(self, obj: Enum) -> Enum: return obj def add_table_group(self, obj: TableGroup) -> TableGroup: - if obj.database == self and obj in self.table_groups: + if obj in self.table_groups: raise DatabaseValidationError(f'{obj} is already in the database.') for table_group in self.table_groups: if table_group.name == obj.name: From f945dc8e9ebe4f7991f7e4a25b83821a3c66197a Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sun, 17 Mar 2024 09:21:43 +0100 Subject: [PATCH 5/6] feat: add sticky notes (v3.2.0) --- LICENSE | 2 +- TODO.md | 1 - docs/classes.md | 13 ++++++ pydbml/classes/base.py | 2 +- pydbml/classes/column.py | 4 +- pydbml/classes/note.py | 7 +-- pydbml/classes/sticky_note.py | 50 +++++++++++++++++++++ pydbml/database.py | 13 +++++- pydbml/definitions/sticky_note.py | 21 +++++++++ pydbml/parser/blueprints.py | 18 ++++++++ pydbml/parser/parser.py | 21 ++++++--- test/test_blueprints/test_sticky_note.py | 53 +++++++++++++++++++++++ test/test_classes/test_sticky_note.py | 46 ++++++++++++++++++++ test/test_data/docs/sticky_notes.dbml | 10 +++++ test/test_definitions/test_sticky_note.py | 35 +++++++++++++++ test/test_docs.py | 13 +++++- test_schema.dbml | 13 +++++- 17 files changed, 303 insertions(+), 19 deletions(-) delete mode 100644 TODO.md create mode 100644 pydbml/classes/sticky_note.py create mode 100644 pydbml/definitions/sticky_note.py create mode 100644 test/test_blueprints/test_sticky_note.py create mode 100644 test/test_classes/test_sticky_note.py create mode 100644 test/test_data/docs/sticky_notes.dbml create mode 100644 test/test_definitions/test_sticky_note.py diff --git a/LICENSE b/LICENSE index 4d20996..c13f991 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 +Copyright (c) 2024 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3fde65e..0000000 --- a/TODO.md +++ /dev/null @@ -1 +0,0 @@ -- schema.add and .delete to support multiple arguments (handle errors properly) diff --git a/docs/classes.md b/docs/classes.md index 1dd14d4..276c834 100644 --- a/docs/classes.md +++ b/docs/classes.md @@ -6,6 +6,7 @@ * [Reference](#reference) * [Enum](#enum) * [Note](#note) +* [StickyNote](#sticky_note) * [Expression](#expression) * [Project](#project) * [TableGroup](#tablegroup) @@ -260,6 +261,18 @@ Note is a basic class, which may appear in some other classes' `note` attribute. * **sql** (str) — SQL definition for this note. * **dbml** (str) — DBML definition for this note. +## Note + +**new in PyDBML 1.0.10** + +Sticky notes are similar to regular notes, except that they are defined at the root of your DBML file and have a name. + +### Attributes + +**name** (str) — note name. +**text** (str) — note text. +* **dbml** (str) — DBML definition for this note. + ## Expression **new in PyDBML 1.0.0** diff --git a/pydbml/classes/base.py b/pydbml/classes/base.py index ec8bfd9..c19ddec 100644 --- a/pydbml/classes/base.py +++ b/pydbml/classes/base.py @@ -9,7 +9,7 @@ class SQLObject: Base class for all SQL objects. ''' required_attributes: Tuple[str, ...] = () - dont_compare_fields = () + dont_compare_fields: Tuple[str, ...] = () def check_attributes_for_sql(self): ''' diff --git a/pydbml/classes/column.py b/pydbml/classes/column.py index d599c2a..c95f3e2 100644 --- a/pydbml/classes/column.py +++ b/pydbml/classes/column.py @@ -46,7 +46,9 @@ def __init__(self, self.default = default self.table: Optional['Table'] = None - def __eq__(self, other: 'Column') -> bool: + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return False self_table = self.table.full_name if self.table else None other_table = other.table.full_name if other.table else None if self_table != other_table: diff --git a/pydbml/classes/note.py b/pydbml/classes/note.py index eee65cf..5dd4dcf 100644 --- a/pydbml/classes/note.py +++ b/pydbml/classes/note.py @@ -9,12 +9,9 @@ class Note(SQLObject): dont_compare_fields = ('parent',) - def __init__(self, text: Union[str, 'Note']) -> None: + def __init__(self, text: Any) -> None: self.text: str - if isinstance(text, Note): - self.text = text.text - else: - self.text = str(text) if text else '' + self.text = str(text) if text is not None else '' self.parent: Any = None def __str__(self): diff --git a/pydbml/classes/sticky_note.py b/pydbml/classes/sticky_note.py new file mode 100644 index 0000000..b843cca --- /dev/null +++ b/pydbml/classes/sticky_note.py @@ -0,0 +1,50 @@ +import re +from typing import Any + +from pydbml.tools import indent + + +class StickyNote: + dont_compare_fields = ('database',) + + def __init__(self, name: str, text: Any) -> None: + self.name = name + self.text = str(text) if text is not None else '' + + self.database = None + + def __str__(self): + ''' + >>> print(StickyNote('mynote', 'Note text')) + StickyNote('mynote', 'Note text') + ''' + + return self.__class__.__name__ + f'({repr(self.name)}, {repr(self.text)})' + + def __bool__(self): + return bool(self.text) + + def __repr__(self): + ''' + >>> StickyNote('mynote', 'Note text') + + ''' + + return f'<{self.__class__.__name__} {self.name!r}, {self.text!r}>' + + def _prepare_text_for_dbml(self): + '''Escape single quotes''' + pattern = re.compile(r"('''|')") + return pattern.sub(r'\\\1', self.text) + + @property + def dbml(self): + text = self._prepare_text_for_dbml() + if '\n' in text: + note_text = f"'''\n{text}\n'''" + else: + note_text = f"'{text}'" + + note_text = indent(note_text) + result = f'Note {self.name} {{\n{note_text}\n}}' + return result diff --git a/pydbml/database.py b/pydbml/database.py index 910b6c5..1347909 100644 --- a/pydbml/database.py +++ b/pydbml/database.py @@ -4,11 +4,12 @@ from typing import Optional from typing import Union -from .classes import Enum +from .classes import Enum, Note from .classes import Project from .classes import Reference from .classes import Table from .classes import TableGroup +from .classes.sticky_note import StickyNote from .exceptions import DatabaseValidationError from .constants import MANY_TO_ONE, ONE_TO_MANY @@ -41,6 +42,7 @@ def __init__(self) -> None: self.refs: List['Reference'] = [] self.enums: List['Enum'] = [] self.table_groups: List['TableGroup'] = [] + self.sticky_notes: List['StickyNote'] = [] self.project: Optional['Project'] = None def __repr__(self) -> str: @@ -79,6 +81,8 @@ def add(self, obj: Any) -> Any: return self.add_table_group(obj) elif isinstance(obj, Project): return self.add_project(obj) + elif isinstance(obj, StickyNote): + return self.add_sticky_note(obj) else: raise DatabaseValidationError(f'Unsupported type {type(obj)}.') @@ -125,6 +129,11 @@ def add_enum(self, obj: Enum) -> Enum: self.enums.append(obj) return obj + def add_sticky_note(self, obj: StickyNote) -> StickyNote: + self._set_database(obj) + self.sticky_notes.append(obj) + return obj + def add_table_group(self, obj: TableGroup) -> TableGroup: if obj in self.table_groups: raise DatabaseValidationError(f'{obj} is already in the database.') @@ -216,7 +225,7 @@ def dbml(self): '''Generates DBML code out of parsed results''' items = [self.project] if self.project else [] refs = (ref for ref in self.refs if not ref.inline) - items.extend((*self.enums, *self.tables, *refs, *self.table_groups)) + items.extend((*self.enums, *self.tables, *refs, *self.table_groups, *self.sticky_notes)) components = ( i.dbml for i in items ) diff --git a/pydbml/definitions/sticky_note.py b/pydbml/definitions/sticky_note.py new file mode 100644 index 0000000..ebd1848 --- /dev/null +++ b/pydbml/definitions/sticky_note.py @@ -0,0 +1,21 @@ +import pyparsing as pp + +from .common import _, end, _c +from .generic import string_literal, name +from ..parser.blueprints import StickyNoteBlueprint + +sticky_note = _c + pp.CaselessLiteral('note') + _ + (name('name') + _ - '{' + _ - string_literal('text') + _ - '}') + end + + +def parse_sticky_note(s, loc, tok): + ''' + Note single_line_note { + 'This is a single line note' + } + ''' + init_dict = {'name': tok['name'], 'text': tok['text']} + + return StickyNoteBlueprint(**init_dict) + + +sticky_note.set_parse_action(parse_sticky_note) diff --git a/pydbml/parser/blueprints.py b/pydbml/parser/blueprints.py index a4e0ecc..8ceea10 100644 --- a/pydbml/parser/blueprints.py +++ b/pydbml/parser/blueprints.py @@ -17,6 +17,7 @@ from pydbml.classes import Reference from pydbml.classes import Table from pydbml.classes import TableGroup +from pydbml.classes.sticky_note import StickyNote from pydbml.exceptions import ColumnNotFoundError from pydbml.exceptions import TableNotFoundError from pydbml.exceptions import ValidationError @@ -43,6 +44,23 @@ def build(self) -> 'Note': return Note(text) +@dataclass +class StickyNoteBlueprint(Blueprint): + name: str + text: str + + def _preformat_text(self) -> str: + '''Preformat the note text for idempotence''' + result = strip_empty_lines(self.text) + result = remove_indentation(result) + return result + + def build(self) -> StickyNote: + text = self._preformat_text() + name = self.name + return StickyNote(name=name, text=text) + + @dataclass class ExpressionBlueprint(Blueprint): text: str diff --git a/pydbml/parser/parser.py b/pydbml/parser/parser.py index 9fd0531..0f3dabb 100644 --- a/pydbml/parser/parser.py +++ b/pydbml/parser/parser.py @@ -1,4 +1,5 @@ from __future__ import annotations + from io import TextIOWrapper from pathlib import Path from typing import List @@ -7,22 +8,22 @@ import pyparsing as pp -from .blueprints import EnumBlueprint -from .blueprints import ProjectBlueprint -from .blueprints import ReferenceBlueprint -from .blueprints import TableBlueprint -from .blueprints import TableGroupBlueprint from pydbml.classes import Table from pydbml.database import Database from pydbml.definitions.common import comment from pydbml.definitions.enum import enum from pydbml.definitions.project import project from pydbml.definitions.reference import ref +from pydbml.definitions.sticky_note import sticky_note from pydbml.definitions.table import table from pydbml.definitions.table_group import table_group from pydbml.exceptions import TableNotFoundError from pydbml.tools import remove_bom - +from .blueprints import EnumBlueprint, StickyNoteBlueprint +from .blueprints import ProjectBlueprint +from .blueprints import ReferenceBlueprint +from .blueprints import TableBlueprint +from .blueprints import TableGroupBlueprint pp.ParserElement.set_default_whitespace_chars(' \t\r') @@ -99,6 +100,7 @@ def __init__(self, source: str): self.refs: List[ReferenceBlueprint] = [] self.enums: List[EnumBlueprint] = [] self.project: Optional[ProjectBlueprint] = None + self.sticky_notes: List[StickyNoteBlueprint] = [] def parse(self): self._set_syntax() @@ -120,12 +122,14 @@ def _set_syntax(self): enum_expr = enum.copy() table_group_expr = table_group.copy() project_expr = project.copy() + note_expr = sticky_note.copy() table_expr.addParseAction(self.parse_blueprint) ref_expr.addParseAction(self.parse_blueprint) enum_expr.addParseAction(self.parse_blueprint) table_group_expr.addParseAction(self.parse_blueprint) project_expr.addParseAction(self.parse_blueprint) + note_expr.addParseAction(self.parse_blueprint) expr = ( table_expr @@ -133,6 +137,7 @@ def _set_syntax(self): | enum_expr | table_group_expr | project_expr + | note_expr ) self._syntax = expr[...] + ('\n' | comment)[...] + pp.StringEnd() @@ -169,6 +174,8 @@ def parse_blueprint(self, s, loc, tok): self.project = blueprint if blueprint.note: blueprint.note.parser = self + elif isinstance(blueprint, StickyNoteBlueprint): + self.sticky_notes.append(blueprint) else: raise RuntimeError(f'type unknown: {blueprint}') blueprint.parser = self @@ -194,6 +201,8 @@ def build_database(self): self.ref_blueprints.extend(table_bp.get_reference_blueprints()) for table_group_bp in self.table_groups: self.database.add(table_group_bp.build()) + for note_bp in self.sticky_notes: + self.database.add(note_bp.build()) if self.project: self.database.add(self.project.build()) for ref_bp in self.refs: diff --git a/test/test_blueprints/test_sticky_note.py b/test/test_blueprints/test_sticky_note.py new file mode 100644 index 0000000..5cdcd9c --- /dev/null +++ b/test/test_blueprints/test_sticky_note.py @@ -0,0 +1,53 @@ +from unittest import TestCase + +from pydbml.classes.sticky_note import StickyNote +from pydbml.parser.blueprints import StickyNoteBlueprint + +class TestNote(TestCase): + def test_build(self) -> None: + bp = StickyNoteBlueprint(name='mynote', text='Note text') + result = bp.build() + self.assertIsInstance(result, StickyNote) + self.assertEqual(result.name, bp.name) + self.assertEqual(result.text, bp.text) + + def test_preformat_not_needed(self): + oneline = 'One line of note text' + multiline = 'Multiline\nnote\n\ntext' + 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.' + + bp = StickyNoteBlueprint(name='mynote', text=oneline) + self.assertEqual(bp.name, bp.name) + self.assertEqual(bp._preformat_text(), oneline) + bp = StickyNoteBlueprint(name='mynote', text=multiline) + self.assertEqual(bp.name, bp.name) + self.assertEqual(bp._preformat_text(), multiline) + bp = StickyNoteBlueprint(name='mynote', text=long_line) + self.assertEqual(bp.name, bp.name) + self.assertEqual(bp._preformat_text(), long_line) + + def test_preformat_needed(self): + uniform_indentation = ' line1\n line2\n line3' + varied_indentation = ' line1\n line2\n\n line3' + empty_lines = '\n\n\n\n\n\n\nline1\nline2\nline3\n\n\n\n\n\n\n' + empty_indented_lines = '\n \n\n \n\n line1\n line2\n line3\n\n\n\n \n\n\n' + + exptected = 'line1\nline2\nline3' + bp = StickyNoteBlueprint(name='mynote', text=uniform_indentation) + self.assertEqual(bp._preformat_text(), exptected) + self.assertEqual(bp.name, bp.name) + + exptected = 'line1\n line2\n\n line3' + bp = StickyNoteBlueprint(name='mynote', text=varied_indentation) + self.assertEqual(bp._preformat_text(), exptected) + self.assertEqual(bp.name, bp.name) + + exptected = 'line1\nline2\nline3' + bp = StickyNoteBlueprint(name='mynote', text=empty_lines) + self.assertEqual(bp._preformat_text(), exptected) + self.assertEqual(bp.name, bp.name) + + exptected = 'line1\nline2\nline3' + bp = StickyNoteBlueprint(name='mynote', text=empty_indented_lines) + self.assertEqual(bp._preformat_text(), exptected) + self.assertEqual(bp.name, bp.name) diff --git a/test/test_classes/test_sticky_note.py b/test/test_classes/test_sticky_note.py new file mode 100644 index 0000000..1727e83 --- /dev/null +++ b/test/test_classes/test_sticky_note.py @@ -0,0 +1,46 @@ +from pydbml.classes import Table +from pydbml.classes import Index +from pydbml.classes import Column +from unittest import TestCase + +from pydbml.classes.sticky_note import StickyNote + + +class TestNote(TestCase): + def test_init_types(self): + n1 = StickyNote('mynote', 'My note text') + n2 = StickyNote('mynote', 3) + n3 = StickyNote('mynote', [1, 2, 3]) + n4 = StickyNote('mynote', None) + + self.assertEqual(n1.text, 'My note text') + self.assertEqual(n2.text, '3') + self.assertEqual(n3.text, '[1, 2, 3]') + self.assertEqual(n4.text, '') + self.assertTrue(n1.name == n2.name == n3.name == n4.name == 'mynote') + + def test_oneline(self): + note = StickyNote('mynote', 'One line of note text') + expected = \ +'''Note mynote { + 'One line of note text' +}''' + self.assertEqual(note.dbml, expected) + + def test_forced_multiline(self): + note = StickyNote('mynote', 'The number of spaces you use to indent a block string\nwill\nbe the minimum number of leading spaces among all lines. The parser wont automatically remove the number of indentation spaces in the final output.') + expected = \ +"""Note mynote { + ''' + The number of spaces you use to indent a block string + will + be the minimum number of leading spaces among all lines. The parser wont automatically remove the number of indentation spaces in the final output. + ''' +}""" + self.assertEqual(note.dbml, expected) + + def test_prepare_text_for_dbml(self): + quotes = "'asd' There's ''' asda '''' asd ''''' asdsa ''" + expected = "\\'asd\\' There\\'s \\''' asda \\'''\\' asd \\'''\\'\\' asdsa \\'\\'" + note = StickyNote('mynote', quotes) + self.assertEqual(note._prepare_text_for_dbml(), expected) diff --git a/test/test_data/docs/sticky_notes.dbml b/test/test_data/docs/sticky_notes.dbml new file mode 100644 index 0000000..7f03cf7 --- /dev/null +++ b/test/test_data/docs/sticky_notes.dbml @@ -0,0 +1,10 @@ +Note single_line_note { + 'This is a single line note' +} + +Note multiple_lines_note { +''' + This is a multiple lines note + This string can spans over multiple lines. +''' +} diff --git a/test/test_definitions/test_sticky_note.py b/test/test_definitions/test_sticky_note.py new file mode 100644 index 0000000..2870b9d --- /dev/null +++ b/test/test_definitions/test_sticky_note.py @@ -0,0 +1,35 @@ +from unittest import TestCase + +from pyparsing import ParseSyntaxException + +from pydbml.definitions.sticky_note import sticky_note + + +class TestSticky(TestCase): + def test_single_quote(self) -> None: + val = "note mynote {'test note'}" + res = sticky_note.parse_string(val, parseAll=True) + self.assertEqual(res[0].name, 'mynote') + self.assertEqual(res[0].text, 'test note') + + def test_double_quote(self) -> None: + val = 'note \n\nmynote\n\n {\n\n"test note"\n\n}' + res = sticky_note.parse_string(val, parseAll=True) + self.assertEqual(res[0].name, 'mynote') + self.assertEqual(res[0].text, 'test note') + + def test_multiline(self) -> None: + val = "note\nmynote\n{ '''line1\nline2\nline3'''}" + res = sticky_note.parse_string(val, parseAll=True) + self.assertEqual(res[0].name, 'mynote') + self.assertEqual(res[0].text, 'line1\nline2\nline3') + + def test_unclosed_quote(self) -> None: + val = 'note mynote{ "test note}' + with self.assertRaises(ParseSyntaxException): + sticky_note.parse_string(val, parseAll=True) + + def test_not_allowed_multiline(self) -> None: + val = "note mynote { 'line1\nline2\nline3' }" + with self.assertRaises(ParseSyntaxException): + sticky_note.parse_string(val, parseAll=True) diff --git a/test/test_docs.py b/test/test_docs.py index 3880303..c5025bd 100644 --- a/test/test_docs.py +++ b/test/test_docs.py @@ -238,7 +238,6 @@ def test_enum_definition(self) -> None: self.assertEqual(g.name, 'grade') self.assertEqual([ei.name for ei in g.items], ['A+', 'A', 'A-', 'Not Yet Set']) - def test_table_group(self) -> None: results = PyDBML.parse_file(TEST_DOCS_PATH / 'table_group.dbml') @@ -253,3 +252,15 @@ def test_table_group(self) -> None: self.assertEqual(tg1.items, [tb1, tb2, tb3]) self.assertEqual(tg2.name, 'e_commerce1') self.assertEqual(tg2.items, [merchants, countries]) + + def test_sticky_notes(self) -> None: + results = PyDBML.parse_file(TEST_DOCS_PATH / 'sticky_notes.dbml') + + self.assertEqual(len(results.sticky_notes), 2) + + sn1, sn2 = results.sticky_notes + + self.assertEqual(sn1.name, 'single_line_note') + self.assertEqual(sn1.text, 'This is a single line note') + self.assertEqual(sn2.name, 'multiple_lines_note') + self.assertEqual(sn2.text, '''This is a multiple lines note\nThis string can spans over multiple lines.''') diff --git a/test_schema.dbml b/test_schema.dbml index 2739c7d..432c1ca 100644 --- a/test_schema.dbml +++ b/test_schema.dbml @@ -74,7 +74,7 @@ Table "merchants" { } -Ref:"products"."id" < "order_items"."product_id" +Ref:"products"."id" < "order_items"."product_id" [update: set default, delete: set null] Ref:"countries"."code" < "users"."country_code" @@ -89,3 +89,14 @@ Table "countries" { "name" varchar "continent_name" varchar } + +Note sticky_note1 { + 'One line note' +} + +Note sticky_note2 { + ''' + # Title + body + ''' +} From 4f21e39b72b3ca2c0a835221f68f8004d09c7986 Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sun, 17 Mar 2024 09:26:28 +0100 Subject: [PATCH 6/6] chore: bump version, update readme and changelog --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0290b03..c47a577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.0.10 +- New: Sticky notes syntax (DBML v3.2.0) +- Fix: Table header color was not rendered in `dbml()` (thanks @tristangrebot for the contribution) +- New: allow array column types (DBML v3.1.0) +- New: allow double quotes in expressions (DBML v3.1.2) +- Fix: recursion in object equality check +- New: don't allow duplicate refs even if they have different inline method (DBML v3.1.6) + # 1.0.9 - Fix: enum collision from different schemas. Thanks @ewdurbin for the contribution diff --git a/README.md b/README.md index bec1991..2f1e426 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # DBML parser for Python -*Compliant with DBML **v2.6.1** syntax* +*Compliant with DBML **v3.2.0** syntax* PyDBML is a Python parser and builder for [DBML](https://www.dbml.org) syntax. diff --git a/setup.py b/setup.py index 0aa37c5..bc65cb8 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ description=SHORT_DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type='text/markdown', - version='1.0.9', + version='1.0.10', author='Daniil Minukhin', author_email='ddddsa@gmail.com', url='https://github.com/Vanderhoof/PyDBML',