diff --git a/backend/lib/azimutt.ex b/backend/lib/azimutt.ex index 11f93373c..e4a3699ee 100644 --- a/backend/lib/azimutt.ex +++ b/backend/lib/azimutt.ex @@ -136,8 +136,8 @@ defmodule Azimutt do project_share: %{name: "Sharing project", free: false, solo: false, team: false, enterprise: true, pro: true, description: "Use private links & embed to share with guest."}, api: %{name: "API access", free: false, solo: false, team: false, enterprise: true, pro: true, description: "Fetch and update sources and documentation programmatically."}, sso: %{name: "SSO", free: false, solo: false, team: false, enterprise: true, pro: false, description: "Soon..."}, - user_rights: %{name: "User rights", free: false, solo: false, team: false, enterprise: true, pro: false, description: "Soon... Have read-only users in your organization."}, - gateway_custom: %{name: "Custom gateway", free: false, solo: false, team: false, enterprise: true, pro: false, description: "Soon... Securely connect to your databases."}, + user_rights: %{name: "User rights", free: false, solo: false, team: false, enterprise: true, pro: false}, + gateway_custom: %{name: "Custom gateway", free: false, solo: false, team: false, enterprise: true, pro: false}, billing: %{name: "Flexible billing", free: false, solo: false, team: false, enterprise: true, pro: false}, support_on_premise: %{name: "On-premise support", free: false, solo: false, team: false, enterprise: true, pro: false}, support_enterprise: %{name: "Enterprise support", free: false, solo: false, team: false, enterprise: true, pro: false, description: "Priority email, answer within 48h."}, @@ -321,6 +321,7 @@ defmodule Azimutt do %{id: "mongodb", name: "MongoDB", parse: false, generate: false}, %{id: "mariadb", name: "MariaDB", parse: false, generate: false}, %{id: "prisma", name: "Prisma", parse: false, generate: false}, + %{id: "dot", name: "DOT", parse: false, generate: true}, %{id: "mermaid", name: "Mermaid", parse: false, generate: true}, %{id: "quicksql", name: "Quick SQL", parse: false, generate: false}, %{id: "markdown", name: "Markdown", parse: false, generate: true}, @@ -399,8 +400,8 @@ defmodule Azimutt do %{path: ["converters"], name: "Converters"} ] }, - %{path: ["installation"], name: "Installation"}, - %{path: ["data-privacy"], name: "Data privacy", details: "how Azimutt keep your data safe"} + %{path: ["data-privacy"], name: "Data privacy", details: "how Azimutt keep your data safe"}, + %{path: ["installation"], name: "Installation"} ] end diff --git a/backend/lib/azimutt_web/templates/layout/_hello_comment.html.heex b/backend/lib/azimutt_web/templates/layout/_hello_comment.html.heex index 812fca6a8..95aaa0558 100644 --- a/backend/lib/azimutt_web/templates/layout/_hello_comment.html.heex +++ b/backend/lib/azimutt_web/templates/layout/_hello_comment.html.heex @@ -1,6 +1,6 @@ - - - - - - + + + + + + diff --git a/backend/lib/azimutt_web/templates/layout/root_elm.html.heex b/backend/lib/azimutt_web/templates/layout/root_elm.html.heex index 2b671045c..19e314874 100644 --- a/backend/lib/azimutt_web/templates/layout/root_elm.html.heex +++ b/backend/lib/azimutt_web/templates/layout/root_elm.html.heex @@ -19,7 +19,7 @@ window.gateway_url = '<%= org_gateway || cc_gateway || Azimutt.config(:gateway_url) %>' window.role = '<%= member && member.role || :owner %>' window.params = {}<%= for param <- ["database", "sql", "prisma", "json", "aml", "empty", "project", "sample", "name", "storage"] |> Enum.filter(fn p -> @conn.params[p] end) do %> - window.params.<%= param %> = '<%= @conn.params[param] %>'<% end %> + window.params.<%= param %> = `<%= raw(@conn.params[param] |> String.replace("`", "\\`")) %>`<% end %> <%= if Azimutt.config(:sentry_frontend_dsn) do %> + @@ -86,23 +86,33 @@ function monacoLang(lang) { if (lang === 'amlv1') return 'aml' if (lang === 'postgres') return 'pgsql' + if (lang === 'dot') return 'plaintext' if (lang === 'mermaid') return 'plaintext' return lang } function parse(lang, content) { - if (lang === 'aml') return aml.parseAml(content) - if (lang === 'amlv1') return aml.parseAml(content).mapError(errs => errs.filter(e => e.kind !== 'LegacySyntax')) - if (lang === 'json') return aml.parseJsonDatabase(content) - return {errors: [{message: 'Unsupported source dialect: ' + lang, kind: 'UnsupportedDialect', level: 'error', offset: {start: 0, end: 100}, position: {start: {line: 1, column: 1}, end: {line: 10, column: 10}}}]} + try { + if (lang === 'aml') return aml.parseAml(content) + if (lang === 'amlv1') return aml.parseAml(content).mapError(errs => errs.filter(e => e.kind !== 'LegacySyntax')) + if (lang === 'json') return aml.parseJsonDatabase(content) + return {errors: [{message: 'Unsupported source dialect: ' + lang, kind: 'UnsupportedDialect', level: 'error', offset: {start: 0, end: 100}, position: {start: {line: 1, column: 1}, end: {line: 10, column: 10}}}]} + } catch (e) { + return {errors: [{message: 'Failed to parse ' + lang + (e && e.message ? ': ' + e.message : ''), kind: 'DialectError', level: 'error', offset: {start: 0, end: 100}, position: {start: {line: 1, column: 1}, end: {line: 10, column: 10}}}]} + } } function format(lang, db) { - if (lang === 'aml') return aml.generateAml(db) - if (lang === 'amlv1') return aml.generateAml(db, true) - if (lang === 'json') return aml.generateJsonDatabase(db) - if (lang === 'postgres') return sql.generateSql(db, 'postgres') - if (lang === 'mermaid') return aml.generateMermaid(db) - if (lang === 'markdown') return aml.generateMarkdown(db) - return 'Unsupported destination dialect: ' + lang + try { + if (lang === 'aml') return aml.generateAml(db) + if (lang === 'amlv1') return aml.generateAml(db, true) + if (lang === 'json') return aml.generateJsonDatabase(db) + if (lang === 'postgres') return sql.generateSql(db, 'postgres') + if (lang === 'dot') return aml.generateDot(db) + if (lang === 'mermaid') return aml.generateMermaid(db) + if (lang === 'markdown') return aml.generateMarkdown(db) + return 'Unsupported destination dialect: ' + lang + } catch (e) { + return 'Failed to generate ' + lang + (e && e.message ? ': ' + e.message : '') + } } function getDefaultValue(lang) { // default value from: `value` query param, or url hash, or local storage, or hardcoded diff --git a/backend/lib/azimutt_web/templates/website/converters/convert.html.heex b/backend/lib/azimutt_web/templates/website/converters/convert.html.heex index 6dc206ad6..935d6b29c 100644 --- a/backend/lib/azimutt_web/templates/website/converters/convert.html.heex +++ b/backend/lib/azimutt_web/templates/website/converters/convert.html.heex @@ -52,11 +52,17 @@
<%= @from.name %>
-

<%= render "converters/_description-short.html", conn: @conn, converter: @from.id %>

+

+ <%= render "converters/_description-short.html", conn: @conn, converter: @from.id %> + <%= render "converters/_description-parse.html", conn: @conn, converter: @from.id %> +

<%= @to.name %>
-

<%= render "converters/_description-short.html", conn: @conn, converter: @to.id %>

+

+ <%= render "converters/_description-short.html", conn: @conn, converter: @to.id %> + <%= render "converters/_description-generate.html", conn: @conn, converter: @to.id %> +

diff --git a/backend/priv/static/images/converters/dot.jpg b/backend/priv/static/images/converters/dot.jpg new file mode 100644 index 000000000..34016d7f6 Binary files /dev/null and b/backend/priv/static/images/converters/dot.jpg differ diff --git a/libs/aml/README.md b/libs/aml/README.md index 9195007f5..88a65dcb0 100644 --- a/libs/aml/README.md +++ b/libs/aml/README.md @@ -87,6 +87,7 @@ You can have both, a `result` and some `errors` as there is syntax recovery and This package also offer a few more features: ```typescript +function generateDot(database: Database) {} // generate a DOT graph function generateMermaid(database: Database) {} // generate a Mermaid erDiagram function generateMarkdown(database: Database) {} // generate markdown documentation function generateJsonDatabase(database: Database): string {} // generate nice JSON (similar to `JSON.stringify(db, null, 2), but more compact) diff --git a/libs/aml/package.json b/libs/aml/package.json index 2d1ba7873..ca8022c2b 100644 --- a/libs/aml/package.json +++ b/libs/aml/package.json @@ -1,6 +1,6 @@ { "name": "@azimutt/aml", - "version": "0.1.5", + "version": "0.1.6", "description": "Parse and Generate AML: the easiest language to define database schema.", "keywords": ["DSL", "language", "database", "parser"], "homepage": "https://azimutt.app/aml", diff --git a/libs/aml/resources/full.dot b/libs/aml/resources/full.dot new file mode 100644 index 000000000..21e492a06 --- /dev/null +++ b/libs/aml/resources/full.dot @@ -0,0 +1,117 @@ +digraph { + node [shape=none, margin=0] + + users [label=< + + + + + + + +
users
iduidpk
first_namevarcharunique
last_namevarcharunique
emailvarcharunique
is_adminbool
+ >] + + "cms.posts" [label=< + + + + + + + + + +
cms.posts
idintpk
titlevarchar(100)unique
statuspost_status
contentvarchar
settingsjson
created_attimestamp with time zone
created_byintfk
+ >] + "cms.posts" -> users [label=settings.publish_by] + "cms.posts" -> users [label=created_by] + + post_members [label=< + + + + + +
post_members
post_iduuidpk, fk
user_idintpk, fk
rolevarchar(10)
+ >] + post_members -> "cms.posts" [label=post_id] + post_members -> users [label=user_id] + + "legacy schema.post member details" [label=< + + + + + + +
legacy schema.post member details
post_iduuidpk, fk
user_idintpk, fk
indexint
added byintfk
+ >] + "legacy schema.post member details" -> users [label=added by] + "legacy schema.post member details" -> post_members [label=post_id,user_id] + + comments [label=< + + + + + + + +
comments
iduuidpk
item_kindcomment_itemindex
item_idintfk, index
contentunknown
created_byunknownfk
+ >] + comments -> users [label=created_by] + comments -> users [label=item_id] + comments -> "cms.posts" [label=item_id] + + "db1.web.public.legacy_slug" [label=< + + + + + +
db1.web.public.legacy_slug
old_slugslug
new_slugslug
cur_slugvarcharfk
+ >] + "db1.web.public.legacy_slug" -> "cms.posts" [label=cur_slug] + + organizations [label=< + + + + + +
organizations
idintpk, fk
namevarchar(50)
contentbox
+ >] + organizations -> users [label=id] + + "identity...profiles" [label=< + + + +
identity...profiles
idintpk, fk
+ >] + "identity...profiles" -> users [label=id] + + admins [label=< + + + + + + +
admins
idunknown
first_nameunknown
last_nameunknown
emailunknown
+ >] + + guests [label=< + + +
guests
+ >] + + "social..social_accounts" [label=< + + +
social..social_accounts
+ >] + "social..social_accounts" -> users +} diff --git a/libs/aml/src/dotGenerator.test.ts b/libs/aml/src/dotGenerator.test.ts new file mode 100644 index 000000000..fc0ffb5e1 --- /dev/null +++ b/libs/aml/src/dotGenerator.test.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import {describe, expect, test} from "@jest/globals"; +import {Database, parseJsonDatabase} from "@azimutt/models"; +import {generateDot} from "./dotGenerator"; + +describe('dotGenerator', () => { + test('empty', () => { + expect(generateDot({})).toEqual('digraph {\n node [shape=none, margin=0]\n}\n') + }) + test('basic', () => { + const db: Database = { + entities: [{ + name: 'users', + attrs: [ + {name: 'id', type: 'int'}, + {name: 'name', type: 'varchar'}, + ], + pk: {attrs: [['id']]} + }, { + name: 'posts', + attrs: [ + {name: 'id', type: 'uuid'}, + {name: 'title', type: 'varchar'}, + {name: 'content', type: 'text', doc: 'support markdown'}, + {name: 'author', type: 'int'}, + ], + pk: {attrs: [['id']]} + }], + relations: [ + {src: {entity: 'posts', attrs: [['author']]}, ref: {entity: 'users', attrs: [['id']]}} + ], + stats: { + name: 'Basic db' + } + } + const sql = `digraph "Basic db" { + label = "Basic db" + node [shape=none, margin=0] + + users [label=< + + + + +
users
idintpk
namevarchar
+ >] + + posts [label=< + + + + + + +
posts
iduuidpk
titlevarchar
contenttextdoc: support markdown
authorintfk
+ >] + posts -> users [label=author] +} +` + expect(generateDot(db)).toEqual(sql) + }) + test('full', () => { + const db: Database = parseJsonDatabase(fs.readFileSync('./resources/full.json', 'utf8')).result || {} + const dot = fs.readFileSync('./resources/full.dot', 'utf8') + // const parsed = parseMermaid(dot) + // expect(parsed).toEqual({result: db}) + expect(generateDot(db, {doc: false})).toEqual(dot) + }) +}) diff --git a/libs/aml/src/dotGenerator.ts b/libs/aml/src/dotGenerator.ts new file mode 100644 index 000000000..397737fa7 --- /dev/null +++ b/libs/aml/src/dotGenerator.ts @@ -0,0 +1,80 @@ +import { + Attribute, AttributePath, + attributePathSame, + Database, + Entity, + EntityRef, + entityRefSame, + entityToRef, + Relation +} from "@azimutt/models"; + +export function generateDot(database: Database, opts: {doc?: boolean} = {}): string { + const entities = (database.entities || []).map(e => { + const ref = entityToRef(e) + const entityRelations = (database.relations || []).filter(r => entityRefSame(r.src, ref)) + return '\n' + genEntity(e, entityRelations, {doc: opts.doc === undefined ? true : opts.doc}) + }).join('') + return genHeader(database) + entities + genFooter() +} + +const indent = ' ' + +function genHeader(db: Database) { + const name = db.stats?.name + const def = `digraph ${name ? genIdentifier(name) + ' ' : ''}{\n` + const label = name ? `${indent}label = ${genIdentifier(name)}\n` : '' + const settings = `${indent}node [shape=none, margin=0]\n` + return def + label + settings +} + +function genFooter() { + return '}\n' +} + +function genEntity(e: Entity, relations: Relation[], gen: {doc: boolean}): string { + const name = genEntityName(entityToRef(e)) + const attrs = (e.attrs || []).map(a => genAttribute(a, e, relations, gen)) + const rels = relations.map(r => genRelation(r, (e.attrs || []).find(a => attributePathSame(r.src.attrs[0], [a.name])))) + const entityTable = [ + '\n', + `${indent}\n`, + ...attrs.map(a => indent + a), + '
${name}
\n', + ].map(line => indent + indent + line) + return `${indent}${genIdentifier(name)} [label=<\n${entityTable.join('')}${indent}>]\n${rels.map(r => indent + r).join('')}` +} + +function genAttribute(a: Attribute, e: Entity, relations: Relation[], gen: {doc: boolean}): string { + const pk = e.pk && e.pk.attrs.some(attr => attributePathSame(attr, [a.name])) ? 'pk' : '' + const attrRelations = relations.filter(r => r.src.attrs.some(attr => attributePathSame(attr, [a.name]))) + const fk = attrRelations.length > 0 ? 'fk' : '' + const attrUniques = (e.indexes || []).filter(u => u.unique && u.attrs.some(attr => attributePathSame(attr, [a.name]))) + const uk = attrUniques.length > 0 ? 'unique' : '' + const attrIndexes = (e.indexes || []).filter(u => !u.unique && u.attrs.some(attr => attributePathSame(attr, [a.name]))) + const idx = attrIndexes.length > 0 ? 'index' : '' + const doc = gen.doc && a.doc ? `doc: ${a.doc}` : '' + const attrs = [pk, fk, uk, idx, doc].filter(k => !!k).join(', ') + return `${a.name}${a.type}${attrs}\n` +} + +function genRelation(r: Relation, a: Attribute | undefined): string { + const label = r.src.attrs.length > 0 ? ` [label=${r.src.attrs.map(genAttributePath).join(',')}]` : '' + return `${genIdentifier(genEntityName(r.src))} -> ${genIdentifier(genEntityName(r.ref))}${label}\n` +} + +function genEntityName(e: EntityRef): string { + if (e.database) return [e.database, e.catalog, e.schema, e.entity].map(v => v || '').join('.') + if (e.catalog) return [e.catalog, e.schema, e.entity].map(v => v || '').join('.') + if (e.schema) return [e.schema, e.entity].map(v => v || '').join('.') + return e.entity +} + +function genAttributePath(p: AttributePath): string { + return p.join('.') +} + +function genIdentifier(str: string): string { + if (str.match(/^[a-zA-Z_][a-zA-Z0-9_-]*$/)) return str + return '"' + str + '"' +} diff --git a/libs/aml/src/index.ts b/libs/aml/src/index.ts index bb52503de..b9a825e40 100644 --- a/libs/aml/src/index.ts +++ b/libs/aml/src/index.ts @@ -13,6 +13,7 @@ import {parseAmlAst} from "./amlParser"; import {buildDatabase} from "./amlBuilder"; import {genDatabase} from "./amlGenerator"; import {codeAction, codeLens, completion, createMarker, language} from "./extensions/monaco"; +import {generateDot} from "./dotGenerator"; import {generateMermaid} from "./mermaidGenerator"; import {generateMarkdown} from "./markdownGenerator"; @@ -50,4 +51,4 @@ const version = packageJson.version // make it available locally: `npm run build:browser && cp out/bundle.min.js ../../backend/priv/static/elm/aml.min.js && cp out/bundle.min.js.map ../../backend/priv/static/elm/aml.min.js.map` // update `backend/lib/azimutt_web/templates/website/_converter-editors-script.html.heex` to use local files export * from "@azimutt/models" -export {parseAml, generateAml, parseJsonDatabase, generateJsonDatabase, schemaJsonDatabase, generateMermaid, generateMarkdown, monaco, version} +export {parseAml, generateAml, parseJsonDatabase, generateJsonDatabase, schemaJsonDatabase, generateDot, generateMermaid, generateMarkdown, monaco, version}