From f945dc8e9ebe4f7991f7e4a25b83821a3c66197a Mon Sep 17 00:00:00 2001 From: Vanderhoof Date: Sun, 17 Mar 2024 09:21:43 +0100 Subject: [PATCH] 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 + ''' +}