Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,138 @@
# AL2DBML

Generates a DBML database schema from a Business Central AL project by parsing table and field definitions.

## Prerequisites

No runtime required — AL2DBML is distributed as a self-contained binary.

| Platform | Supported |
|---|---|
| Windows (x64) | ✓ |
| macOS (Apple Silicon) | ✓ |
| Linux (x64) | ✓ |

## Installation

### Windows (PowerShell)

```powershell
$dest = "$env:LOCALAPPDATA\al2dbml"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri "https://github.com/OGR-67/AL2DBML/releases/latest/download/al2dbml-win-x64.exe" -OutFile "$dest\al2dbml.exe"
$path = [Environment]::GetEnvironmentVariable("Path", "User")
if ($path -notlike "*al2dbml*") {
[Environment]::SetEnvironmentVariable("Path", "$path;$dest", "User")
}
Write-Host "Done. Restart your terminal."
```

### macOS

```bash
curl -L https://github.com/OGR-67/AL2DBML/releases/latest/download/al2dbml-osx-arm64 -o al2dbml
chmod +x al2dbml
xattr -d com.apple.quarantine al2dbml
sudo mv al2dbml /usr/local/bin/al2dbml
```

> **Note:** The `xattr` command removes the Gatekeeper quarantine flag that macOS sets on downloaded binaries. Without it, macOS will block the app on first run. If you downloaded the binary manually instead of using this script, go to **System Settings → Privacy & Security** and click **Allow Anyway**.

### Linux

```bash
curl -L https://github.com/OGR-67/AL2DBML/releases/latest/download/al2dbml-linux-x64 -o al2dbml
chmod +x al2dbml
sudo mv al2dbml /usr/local/bin/al2dbml
```

## Quick start

```bash
# 1. Initialize AL2DBML in your project (run once)
al2dbml init

# 2. Generate the DBML schema
al2dbml generate
```

## Commands

### `generate`

Parses AL files and generates a `.dbml` schema file.

```
al2dbml generate [-i <input>] [-o <output>] [-n <name>]
```

| Option | Description | Default |
|---|---|---|
| `-i`, `--input` | Path to an AL project folder, `.al` file, or `.code-workspace` file | Value from `config.local.json`, then `.` |
| `-o`, `--output` | Output directory | Value from `config.json`, then `.` |
| `-n`, `--name` | Output file name (without extension) | Value from `config.json`, then `schema` |

### `init`

Initializes AL2DBML interactively in the current directory. Creates the `.al2dbml/` config folder and optionally sets up a pre-commit hook.

```
al2dbml init
```

Re-running `init` on an existing config pre-fills the prompts with current values.

## Configuration

`init` creates two files in `.al2dbml/`:

| File | Versioned | Content |
|---|---|---|
| `config.json` | Yes | Output path and file name — shared across contributors |
| `config.local.json` | No (gitignored) | Input path — contributor-specific, useful for workspaces |

`config.json` example:
```json
{
"Output": {
"Path": "./docs/",
"Name": "schema"
}
}
```

`config.local.json` example:
```json
{
"Input": {
"Path": "./MyProject.code-workspace"
}
}
```

## Pre-commit hook

When enabled during `init`, AL2DBML adds a section to `.git/hooks/pre-commit` that automatically regenerates the schema on each commit:

```sh
# [al2dbml-start]
if command -v al2dbml > /dev/null 2>&1; then
al2dbml generate || printf "\033[33mWarning: al2dbml generate failed, skipping DBML update.\033[0m\n"
else
printf "\033[33mWarning: al2dbml not found, skipping DBML update.\033[0m\n"
fi
# [al2dbml-end]
```

If `al2dbml` is not installed on a contributor's machine, the hook prints a warning and lets the commit proceed normally.

To remove the hook, run:

```bash
al2dbml remove-hook
Comment on lines +129 to +132

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

al2dbml remove-hook is documented here, but the CLI currently only registers generate and init commands (no remove-hook). Either add a remove-hook command, or update the README to describe manual removal (delete the section between # [al2dbml-start] / # [al2dbml-end] in .git/hooks/pre-commit).

Suggested change
To remove the hook, run:
```bash
al2dbml remove-hook
To remove the hook, open `.git/hooks/pre-commit` in a text editor and delete the section between:
```sh
# [al2dbml-start]
...
# [al2dbml-end]

Copilot uses AI. Check for mistakes.
```

## Contributing

- [Architecture](docs/ARCHITECTURE.md) — project structure, CLI design, DI setup
- [AL Parsing](docs/AL_PARSING.md) — AL syntax specifics and parsing decisions
107 changes: 107 additions & 0 deletions docs/AL_PARSING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# AL File Parsing

This document describes the AL parsing specifics relevant to this project. It does not cover the full AL language specification — only what is needed to generate DBML output.

## Supported file types

| AL type | Detection keyword | Handled by |
|---|---|---|
| Table | `table` | `ParseTable` |
| Table extension | `tableextension` | `ParseTableExtension` |
| Enum | `enum` | `ParseEnum` |
| Enum extension | `enumextension` | `ParseEnumExtension` |
| Other (page, report, codeunit...) | — | Silently skipped |

## File type detection

Detection is done by regex on the **file content**, not the filename or extension. The detection order matters:

1. `enumextension` before `enum` — an extension file could partially match the enum pattern
2. `tableextension` before `table` — same reason

## Object identifiers

Every AL object has a numeric ID: `table 50100 "Customer"`. The ID is **ignored** — only the name is extracted and used in the DBML output.

## Name conventions

AL identifiers can be quoted or unquoted:
- `"My Field"` → quoted, typically used for names with spaces or special characters
- `MyField` → unquoted

Names containing `/` must be re-quoted in DBML output (e.g. `"No./Name"`) since `/` is not valid in an unquoted DBML identifier. This is handled by `AlSyntaxHelper.CleanName`.

## Table parsing

### Fields

AL field syntax:
```al
field(50; "My Field"; Text[100]) { ... }
```

The field body `{ ... }` is parsed for:
- `FieldClass = FlowField` → marks the column as a FlowField
- `CalcFormula = ...` → extracts the formula as a string

### Primary keys

Primary keys are detected from `key(...)` declarations:
- A key named `PK` is treated as the primary key
- A key with `Clustered = true` in its body is treated as the primary key
- Composite keys (comma-separated fields) are supported

```al
key(PK; "No.") { Clustered = true; }
```

## Table extensions

A `tableextension` extends an existing base table:
```al
tableextension 50100 "My Extension" extends "Customer" { ... }
```

Fields from the extension are **merged** into the base table's `DBMLTable`. If the base table has not been parsed yet, a new `DBMLTable` is created for it and will be enriched when the base table file is processed. Parse order across files therefore does not matter.

## Enum parsing

An enum can optionally implement one or more interfaces via the `implements` clause:

```al
enum 50100 "My Status" implements "IStatus", "ILabel"
{
value(0; Open) { Caption = 'Open'; }
value(1; Closed) { Caption = 'Closed'; }
}
```

The `implements` clause is handled by the detection regex but the interface names are **not captured** in the output — only the enum name and its values matter for DBML. The corresponding interface definition files (`.al` files containing `interface "IStatus" { ... }`) are `Unknown` type and silently skipped.

### Detection order caveat

The `Enum` detection pattern starts with `^\s*enum\s+`. Since `enumextension` also starts with `enum`, checking `Enum` before `EnumExtension` would misclassify extension files. The correct order is always `EnumExtension` → `Enum`.

## Enum extensions

```al
enumextension 50101 "My Status Ext" extends "My Status" { value(2; Pending) }
```

Values are merged into the base enum. Duplicate values are ignored.

## FlowFields

AL FlowFields are calculated fields with no physical storage:
```al
field(20; "Balance"; Decimal) {
FieldClass = FlowField;
CalcFormula = sum("Ledger Entry".Amount where("Account No." = field("No.")));
}
```

FlowFields are included in the DBML output as regular columns. The `CalcFormula` is captured and can be used as a note. DBML has no native equivalent for this concept.

## State accumulation

`IAlParser` accumulates parsed tables and enums in internal state across multiple `Parse*` calls. This allows multi-file parsing (folder, workspace) to produce a single unified `OutputSchema`. State is cleared between command executions via `ClearOutputSchema()`.
62 changes: 62 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

AL2DBML follows a Clean Architecture pattern: the CLI resolves dependencies through a dedicated Composition Root, while the Parser acts as infrastructure implementing contracts defined by the Application layer — keeping the Domain model free of any external dependency.

## Dependency graph

```mermaid
graph TD
CLI["AL2DBML.CLI<br/><i>Presentation</i>"]
Expand All @@ -24,3 +26,63 @@ graph TD
TESTS --> CORE
TESTS --> DI
```

## CLI layer

The CLI is built on **Spectre.Console.Cli** and exposes two commands:

| Command | Description |
|---|---|
| `generate` | Parses AL input and writes a `.dbml` file |
| `init` | Initializes the `.al2dbml/` config directory interactively |

### Input strategy pattern

`generate` delegates input handling to an `IInputStrategy` resolved by `InputStrategyFactory` based on the detected input type:

```mermaid
graph TD
GC["GenerateCommand"]
F["InputStrategyFactory"]
SF["SingleFileInputStrategy"]
FO["FolderInputStrategy"]
WS["WorkspaceInputStrategy"]

GC --> F
F --> SF
F --> FO
F --> WS
FO --> SF
WS --> FO
```

| Strategy | Input | Behaviour |
|---|---|---|
| `SingleFileInputStrategy` | `.al` file | Detects file type, calls the appropriate parser method |
| `FolderInputStrategy` | Directory | Scans for `*.al` recursively, delegates each file to `SingleFileInputStrategy` |
| `WorkspaceInputStrategy` | `.code-workspace` | Reads workspace JSON, resolves each folder path, delegates to `FolderInputStrategy` |

Files with an unsupported type (`AlFileType.Unknown`) are silently skipped — this is intentional since AL projects contain many non-table/enum objects.

### Configuration

`init` creates a `.al2dbml/` directory at the project root with two files:

| File | Tracked | Content |
|---|---|---|
| `config.json` | versioned | output path and file name (shared across contributors) |
| `config.local.json` | gitignored | input path (contributor-specific, especially for workspaces) |

`generate` reads these files if present; CLI arguments always take precedence.

### Services

| Service | Scope | Responsibility |
|---|---|---|
| `FileSystemService` | static | Input type detection, directory scan |
| `ConfigService` | scoped | Read/write `.al2dbml/` config files |
| `ParsingTracker` | scoped | Track file count and elapsed time per command execution |

### DI integration

Spectre.Console.Cli is integrated with `Microsoft.Extensions.DependencyInjection` via a custom `TypeRegistrar`/`TypeResolver`. A new DI scope is created per command execution so scoped services (parser state, tracker) are properly isolated.

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This states a new DI scope is created per command execution, but TypeRegistrar.Build() creates a single scope once and reuses that scoped ServiceProvider for the app lifetime. Please align the documentation with the actual behavior, or adjust the DI integration to truly create/dispose a scope per command if isolation is required.

Suggested change
Spectre.Console.Cli is integrated with `Microsoft.Extensions.DependencyInjection` via a custom `TypeRegistrar`/`TypeResolver`. A new DI scope is created per command execution so scoped services (parser state, tracker) are properly isolated.
Spectre.Console.Cli is integrated with `Microsoft.Extensions.DependencyInjection` via a custom `TypeRegistrar`/`TypeResolver`. The DI container is built once and a single scope is reused for the application lifetime, so services registered as scoped (such as parser state and trackers) are shared across command executions rather than isolated per command.

Copilot uses AI. Check for mistakes.
Loading