Skip to content

Commit

Permalink
Basic parser for DBML.
Browse files Browse the repository at this point in the history
  • Loading branch information
simonwo committed Sep 13, 2020
0 parents commit 32fdcec
Show file tree
Hide file tree
Showing 10 changed files with 345 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/spec/reports/
/tmp/
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# Specify your gem's dependencies in dbml.gemspec
gemspec
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2020 Simon Worthington

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# DBML

Ruby library for [Database Markup Language (DBML)](https://www.dbml.org/).

## Installation

Add this line to your application's Gemfile:

```ruby
gem 'dbml'
```

And then execute:

$ bundle

Or install it yourself as:

$ gem install dbml

## Usage

```ruby
require 'dbml'
project = DBML::Parser.parse """
Table users {
id int64 [pk, unique]
name varchar [unique]
Note: 'add some more things in here!'
}"""
puts project.tables.first.name
# => "users"
```

## Development

After checking out the repo, run `bundle install` to install dependencies. Then, run `rake test` to run the tests.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/simonwo/dbml-ruby.

## License

The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
10 changes: 10 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
require "bundler/gem_tasks"
require "rake/testtask"

Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/*_test.rb"]
end

task :default => :test
29 changes: 29 additions & 0 deletions dbml.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'dbml/version'

Gem::Specification.new do |spec|
spec.name = 'dbml'
spec.version = DBML::VERSION
spec.authors = ['Simon Worthington']
spec.email = ['[email protected]']

spec.summary = %q{Parser for Database Markup Language (DBML).}
spec.homepage = 'https://github.com/simonwo/dbml-ruby'
spec.license = 'MIT'

# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
end
spec.bindir = 'exe'
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_dependency 'rsec', '~> 1.0'
spec.add_development_dependency 'bundler'
spec.add_development_dependency 'rake'
spec.add_development_dependency 'minitest', '~> 5.0'
end
206 changes: 206 additions & 0 deletions lib/dbml.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
require 'rsec'
include Rsec::Helpers

module DBML
Column = Struct.new :name, :type, :settings
Table = Struct.new :name, :alias, :notes, :columns, :indexes
Index = Struct.new :fields, :settings
Expression = Struct.new :text
Enum = Struct.new :name, :choices
EnumChoice = Struct.new :name, :settings
TableGroup = Struct.new :name, :tables
Project = Struct.new :name, :notes, :settings, :tables, :enums, :table_groups
ProjectDef = Struct.new :name, :notes, :settings

module Parser
def self.long_or_short p
(':'.r >> p) | ('{'.r >> p << '}'.r)
end

def self.unwrap p, *_
if p.empty? then nil else p.first end
end

def self.comma_separated p
p.join(/, */.r.map {|_| nil}).star.map {|v| v.first.reject(&:nil?) }
end

def self.space_surrounded p
/\s*/.r >> p << /\s*/.r
end

def self.block type, name_parser, content_parser, &block
seq_(type.r >> name_parser, '{'.r >> space_surrounded(content_parser).star.map {|a| a.flatten(1) } << '}'.r, &block)
end

BOOLEAN = 'true'.r | 'false'.r
NULL = 'null'.r
NUMBER = prim(:double)
EXPRESSION = seq('`'.r, /[^`]+/.r, '`'.r)[1].map {|str| Expression.new str}
SINGLE_LING_STRING = seq("'".r, /[^']+/.r, "'".r)[1]
MULTI_LINE_STRING = seq("'''".r, /([^']|'[^']|''[^'])+/m.r, "'''".r)[1].map do |string|
# Remove the indentation on the first line from all other lines.
indent = string.match(/^\s*/m)[0].size
string.lines.map do |line|
raise "Indentation does not match" unless line =~ /\s{#{indent}}/
line[indent..]
end.join
end
STRING = SINGLE_LING_STRING | MULTI_LINE_STRING
ATOM = BOOLEAN | NULL | NUMBER | EXPRESSION | STRING

# Each setting item can take in 2 forms: Key: Value or keyword, similar to that of Python function parameters.
SETTING = seq_(/[^,:\[\]\{\}\s]+/.r, (':'.r >> ATOM).maybe(&method(:unwrap))) {|(key, value)| {key => value} }
# Settings are all defined within square brackets: [setting1: value1, setting2: value2, setting3, setting4]
SETTINGS = ('['.r >> comma_separated(SETTING) << ']'.r).map {|values| values.reduce({}, &:update) }

NOTE = 'Note'.r >> (long_or_short STRING)

# Index Definition
#
# Indexes allow users to quickly locate and access the data. Users can define single or multi-column indexes.
#
# Table bookings {
# id integer
# country varchar
# booking_date date
# created_at timestamp
#
# indexes {
# (id, country) [pk] // composite primary key
# created_at [note: 'Date']
# booking_date
# (country, booking_date) [unique]
# booking_date [type: hash]
# (`id*2`)
# (`id*3`,`getdate()`)
# (`id*3`,id)
# }
# }
#
# There are 3 types of index definitions:
#
# Index with single field (with index name): CREATE INDEX on users (created_at)
# Index with multiple fields (composite index): CREATE INDEX on users (created_at, country)
# Index with an expression: CREATE INDEX ON films ( first_name + last_name )
# (bonus) Composite index with expression: CREATE INDEX ON users ( country, (lower(name)) )

INDEX_SINGLE = /[^\(\)\,\{\}\s]+/.r
INDEX_COMPOSITE = seq_('('.r, comma_separated(EXPRESSION | INDEX_SINGLE), ')'.r).inner.map {|v| unwrap(v) }
INDEX = seq_(INDEX_SINGLE | INDEX_COMPOSITE, SETTINGS.maybe).map do |(fields, settings)|
Index.new fields, unwrap(settings)
end
INDEXES = ('indexes {'.r >> INDEX.star << '}'.r).map{|v| p v; v }

# Enum Definition
# ---------------
#
# Enum allows users to define different values of a particular column.
#
# enum job_status {
# created [note: 'Waiting to be processed']
# running
# done
# failure
# }

ENUM_CHOICE = seq_(/\S+/.r, SETTINGS.maybe).map {|(name, settings)| EnumChoice.new name, unwrap(settings) }
ENUM = seq_('enum'.r >> /\S+/.r, '{'.r >> ENUM_CHOICE.star << '}'.r).map {|(name, choices)| Enum.new name, choices }

# Column Definition
# =================
# * name of the column is listed as column_name
# * type of the data in the column listed as column_type
# * supports all data types, as long as it is a single word (remove all spaces in the data type). Example, JSON, JSONB, decimal(1,2), etc.
# * column_name can be stated in just plain text, or wrapped in a double quote as "column name"
#
# Column Settings
# ---------------
# Each column can take have optional settings, defined in square brackets like:
#
# Table buildings {
# ...
# address varchar(255) [unique, not null, note: 'to include unit number']
# id integer [ pk, unique, default: 123, note: 'Number' ]
# }

QUOTED_COLUMN_NAME = '"'.r >> /[^"]+/.r << '"'.r
UNQUOTED_COLUMN_NAME = /\S+/.r
COLUMN_TYPE = /\S+/.r
COLUMN = seq_(
QUOTED_COLUMN_NAME | UNQUOTED_COLUMN_NAME,
COLUMN_TYPE,
SETTINGS.maybe
) {|(name, type, settings)| Column.new name, type, unwrap(settings) }

# Table Definition
#
# Table table_name {
# column_name column_type [column_settings]
# }
#
# * title of database table is listed as table_name
# * list is wrapped in curly brackets {}, for indexes, constraints and table definitions.
# * string value is be wrapped in a single quote as 'string'

TABLE_NAME = seq_ /\S+/.r, ('as'.r >> /\S+/.r).maybe(&method(:unwrap))
TABLE = seq_('Table'.r >> TABLE_NAME, (long_or_short (/\s*/.r >> (INDEXES | NOTE | COLUMN) << /\s*/.r).star)) do |((name, aliaz), objects)|
Table.new name, aliaz,
objects.select {|o| o.is_a? String },
objects.select {|o| o.is_a? Column },
objects.select {|o| (o.is_a? Array) && (o.all? {|e| e.is_a? Index })}.flatten
end

# TableGroup
# ==========
#
# TableGroup allows users to group the related or associated tables together.
#
# TableGroup tablegroup_name { // tablegroup is case-insensitive.
# table1
# table2
# table3
# }
TABLE_GROUP = seq_('TableGroup'.r >> /\S+/.r, '{'.r >> /\S+/.r.star << '}'.r) do |(name, tables)|
TableGroup.new name, tables
end

# Project Definition
# ==================
# You can give overall description of the project.
#
# Project project_name {
# database_type: 'PostgreSQL'
# Note: 'Description of the project'
# }

PROJECT_DEFINITION = block 'Project', /\S+/.r, (NOTE | SETTING).star do |(name, objects)|
ProjectDef.new name,
objects.select {|o| o.is_a? String },
objects.select {|o| o.is_a? Hash }.reduce({}, &:update)
end
PROJECT = space_surrounded(PROJECT_DEFINITION | TABLE | TABLE_GROUP | ENUM).star do |objects|
definition = objects.find {|o| o.is_a? ProjectDef }
Project.new definition.nil? ? nil : definition.name,
definition.nil? ? [] : definition.notes,
definition.nil? ? [] : definition.settings,
objects.select {|o| o.is_a? Table },
objects.select {|o| o.is_a? Enum },
objects.select {|o| o.is_a? TableGroup }
end

def self.parse str
PROJECT.eof.parse! str.gsub(/\/{2}.*$/, '')
end
end
end

if $0 == __FILE__
p DBML::Parser::PROJECT_DEFINITION.parse! "Project geoff {\n database_table: 'PostgreSQL'\n}"
p DBML::Parser::NOTE.parse! "Note { \n''' Simon is\n very wicked'''\n}"
p DBML::Parser::SETTINGS.parse! "[long: 'short', unique, not null, default: 123.45678]"
p DBML::Parser::INDEX.eof.parse! '(id, `id*3`) [pk]'
x = DBML::Parser.parse "Project geoff {\n database_type: 'postgres'\n}\nTable banter {\n id string [pk]// the id \n// TODO: rest of schema\nNote: 'this is a great table'\nindexes { id\n(id, `id*2`)\n} }"
p x.tables
p x.name
end
3 changes: 3 additions & 0 deletions lib/dbml/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module DBML
VERSION = "0.1.0"
end
11 changes: 11 additions & 0 deletions test/dbml_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require "test_helper"

class DbmlTest < Minitest::Test
def test_that_it_has_a_version_number
refute_nil ::Dbml::VERSION
end

def test_it_does_something_useful
assert false
end
end
4 changes: 4 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
$LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
require "dbml"

require "minitest/autorun"

0 comments on commit 32fdcec

Please sign in to comment.