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 |
+ id | uid | pk |
+ first_name | varchar | unique |
+ last_name | varchar | unique |
+ email | varchar | unique |
+ is_admin | bool | |
+
+ >]
+
+ "cms.posts" [label=<
+
+ cms.posts |
+ id | int | pk |
+ title | varchar(100) | unique |
+ status | post_status | |
+ content | varchar | |
+ settings | json | |
+ created_at | timestamp with time zone | |
+ created_by | int | fk |
+
+ >]
+ "cms.posts" -> users [label=settings.publish_by]
+ "cms.posts" -> users [label=created_by]
+
+ post_members [label=<
+
+ post_members |
+ post_id | uuid | pk, fk |
+ user_id | int | pk, fk |
+ role | varchar(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_id | uuid | pk, fk |
+ user_id | int | pk, fk |
+ index | int | |
+ added by | int | fk |
+
+ >]
+ "legacy schema.post member details" -> users [label=added by]
+ "legacy schema.post member details" -> post_members [label=post_id,user_id]
+
+ comments [label=<
+
+ comments |
+ id | uuid | pk |
+ item_kind | comment_item | index |
+ item_id | int | fk, index |
+ content | unknown | |
+ created_by | unknown | fk |
+
+ >]
+ 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_slug | slug | |
+ new_slug | slug | |
+ cur_slug | varchar | fk |
+
+ >]
+ "db1.web.public.legacy_slug" -> "cms.posts" [label=cur_slug]
+
+ organizations [label=<
+
+ organizations |
+ id | int | pk, fk |
+ name | varchar(50) | |
+ content | box | |
+
+ >]
+ organizations -> users [label=id]
+
+ "identity...profiles" [label=<
+
+ identity...profiles |
+ id | int | pk, fk |
+
+ >]
+ "identity...profiles" -> users [label=id]
+
+ admins [label=<
+
+ admins |
+ id | unknown | |
+ first_name | unknown | |
+ last_name | unknown | |
+ email | unknown | |
+
+ >]
+
+ guests [label=<
+
+ >]
+
+ "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 |
+ id | int | pk |
+ name | varchar | |
+
+ >]
+
+ posts [label=<
+
+ posts |
+ id | uuid | pk |
+ title | varchar | |
+ content | text | doc: support markdown |
+ author | int | fk |
+
+ >]
+ 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}${name} |
\n`,
+ ...attrs.map(a => indent + a),
+ '
\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}