Skip to content

Commit

Permalink
Include relationships between tables
Browse files Browse the repository at this point in the history
This commit now handles parsing relationships
which link tables together.

The syntax for inline relationships is a little
funky as it doesn't match our model of single key
and value for settings. So we have to handle
inline relationships specially and always return
an array of them when one is found.

No effort is made (yet) to validate the correctness
of relationships or to add info the inline refs.
  • Loading branch information
simonwo committed Sep 15, 2020
1 parent 1391ac5 commit 13ba82e
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 20 deletions.
111 changes: 94 additions & 17 deletions lib/dbml.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
require 'rsec'

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
Column = Struct.new :name, :type, :settings
Table = Struct.new :name, :alias, :notes, :columns, :indexes
Index = Struct.new :fields, :settings
Expression = Struct.new :text
Relationship = Struct.new :name, :left_table, :left_fields, :type, :right_table, :right_fields, :settings
Enum = Struct.new :name, :choices
EnumChoice = Struct.new :name, :settings
TableGroup = Struct.new :name, :tables
Project = Struct.new :name, :notes, :settings, :tables, :relationships, :enums, :table_groups
ProjectDef = Struct.new :name, :notes, :settings

module Parser
extend Rsec::Helpers
Expand All @@ -23,7 +24,7 @@ def self.unwrap p, *_
end

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

def self.space_surrounded p
Expand Down Expand Up @@ -68,12 +69,20 @@ def self.block type, name_parser, content_parser, &block
# Each setting item can take in 2 forms: Key: Value or keyword, similar to that of Python function parameters.
# Settings are all defined within square brackets: [setting1: value1, setting2: value2, setting3, setting4]
#
SETTINGS = ('['.r >> comma_separated(SETTING) << ']'.r).map {|values| values.reduce({}, &:update) }
# SETTINGS parses key value settings: '[default: 123]' => {default: 123}
# SETTINGS parses keyword settings: '[not null]' => {:'not null' => nil}
# SETTINGS parses many settings: "[some setting: 'value', primary key]" => {:'some setting' => 'value', :'primary key' => nil}
# SETTINGS parses keyword values: "[delete: cascade]" => {delete: :cascade}
# SETTINGS parses relationship form: '[ref: > users.id]' => {ref: [DBML::Relationship.new(nil, nil, [], '>', 'users', ['id'], {})]}
# SETTINGS parses multiple relationships: '[ref: > a.b, ref: < c.d]' => {ref: [DBML::Relationship.new(nil, nil, [], '>', 'a', ['b'], {}), DBML::Relationship.new(nil, nil, [], '<', 'c', ['d'], {})]}
REF_SETTING = 'ref:'.r >> seq_(lazy { RELATIONSHIP_TYPE }, lazy {RELATIONSHIP_PART}).map do |(type, part)|
Relationship.new(nil, nil, [], type, *part, {})
end
SETTING = seq_(KEYWORD, (':'.r >> (ATOM | KEYWORD)).maybe(&method(:unwrap))) {|(key, value)| {key => value} }
SETTINGS = ('['.r >> comma_separated(REF_SETTING | SETTING) << ']'.r).map do |values|
refs, settings = values.partition {|val| val.is_a? Relationship }
[*settings, *(if refs.any? then [{ref: refs}] else [] end)].reduce({}, &:update)
end

# NOTE parses short notes: "Note: 'this is cool'" => 'this is cool'
# NOTE parses block notes: "Note {\n'still a single line of note'\n}" => 'still a single line of note'
Expand Down Expand Up @@ -215,6 +224,73 @@ def self.block type, name_parser, content_parser, &block
TableGroup.new name, tables
end

# Relationships & Foreign Key Definitions
#
# Relationships are used to define foreign key constraints between tables.
#
# Table posts {
# id integer [primary key]
# user_id integer [ref: > users.id] // many-to-one
# }
#
# // or this
# Table users {
# id integer [ref: < posts.user_id, ref: < reviews.user_id] // one to many
# }
#
# // The space after '<' is optional
#
# There are 3 types of relationships: one-to-one, one-to-many, and many-to-one
#
# 1. <: one-to-many. E.g: users.id < posts.user_id
# 2. >: many-to-one. E.g: posts.user_id > users.id
# 3. -: one-to-one. E.g: users.id - user_infos.user_id
#
# Composite foreign keys:
#
# Ref: merchant_periods.(merchant_id, country_code) > merchants.(id, country_code)
#
# In DBML, there are 3 syntaxes to define relationships:
#
# //Long form
# Ref name_optional {
# table1.column1 < table2.column2
# }
#
# //Short form:
# Ref name_optional: table1.column1 < table2.column2
#
# // Inline form
# Table posts {
# id integer
# user_id integer [ref: > users.id]
# }
#
# Relationship settings
#
# Ref: products.merchant_id > merchants.id [delete: cascade, update: no action]
#
# * delete / update: cascade | restrict | set null | set default | no action
# Define referential actions. Similar to ON DELETE/UPDATE CASCADE/... in SQL.
#
# Relationship settings are not supported for inline form ref.
#
# COMPOSITE_COLUMNS parses single column: '(column)' => ['column']
# COMPOSITE_COLUMNS parses multiple columns: '(col1, col2)' => ['col1', 'col2']
# RELATIONSHIP_PART parses simple form: 'table.column' => ['table', ['column']]
# RELATIONSHIP_PART parses composite form: 'table.(a, b)' => ['table', ['a', 'b']]
# RELATIONSHIP parses long form: "Ref name {\nleft.lcol < right.rcol\n}" => DBML::Relationship.new('name', 'left', ['lcol'], '<', 'right', ['rcol'], {})
# RELATIONSHIP parses short form: "Ref name: left.lcol > right.rcol" => DBML::Relationship.new('name', 'left', ['lcol'], '>', 'right', ['rcol'], {})
# RELATIONSHIP parses composite form: 'Ref: left.(a, b) - right.(c, d)' => DBML::Relationship.new(nil, 'left', ['a', 'b'], '-', 'right', ['c', 'd'], {})
# RELATIONSHIP parses settings: "Ref: L.a > R.b [delete: cascade, update: no action]" => DBML::Relationship.new(nil, 'L', ['a'], '>', 'R', ['b'], {delete: :cascade, update: :'no action'})
COMPOSITE_COLUMNS = '('.r >> comma_separated(COLUMN_NAME) << ')'
RELATIONSHIP_TYPE = '>'.r | '<'.r | '-'.r
RELATIONSHIP_PART = seq(seq(IDENTIFIER, '.'.r)[0], (COLUMN_NAME.map {|c| [c]}) | COMPOSITE_COLUMNS)
RELATIONSHIP_BODY = seq_(RELATIONSHIP_PART, RELATIONSHIP_TYPE, RELATIONSHIP_PART, SETTINGS.maybe)
RELATIONSHIP = seq_('Ref'.r >> NAKED_IDENTIFIER.maybe, long_or_short(RELATIONSHIP_BODY)).map do |(name, (left, type, right, settings))|
Relationship.new unwrap(name), *left, type, *right, unwrap(settings) || {}
end

# Project Definition
# ==================
# You can give overall description of the project.
Expand All @@ -233,17 +309,18 @@ def self.block type, name_parser, content_parser, &block
objects.select {|o| o.is_a? Hash }.reduce({}, &:update)
end

# PROJECT can be empty: "" => DBML::Project.new(nil, [], {}, [], [], [])
# PROJECT includes definition info: "Project p { Note: 'hello' }" => DBML::Project.new('p', ['hello'], {}, [], [], [])
# PROJECT includes tables: "Table t { }" => DBML::Project.new(nil, [], {}, [DBML::Table.new('t', nil, [], [], [])], [], [])
# PROJECT includes enums: "enum E { }" => DBML::Project.new(nil, [], {}, [], [DBML::Enum.new('E', [])], [])
# PROJECT includes table groups: "TableGroup TG { }" => DBML::Project.new(nil, [], {}, [], [], [DBML::TableGroup.new('TG', [])])
PROJECT = space_surrounded(PROJECT_DEFINITION | TABLE | TABLE_GROUP | ENUM).star do |objects|
# PROJECT can be empty: "" => DBML::Project.new(nil, [], {}, [], [], [], [])
# PROJECT includes definition info: "Project p { Note: 'hello' }" => DBML::Project.new('p', ['hello'], {}, [], [], [], [])
# PROJECT includes tables: "Table t { }" => DBML::Project.new(nil, [], {}, [DBML::Table.new('t', nil, [], [], [])], [], [], [])
# PROJECT includes enums: "enum E { }" => DBML::Project.new(nil, [], {}, [], [], [DBML::Enum.new('E', [])], [])
# PROJECT includes table groups: "TableGroup TG { }" => DBML::Project.new(nil, [], {}, [], [], [], [DBML::TableGroup.new('TG', [])])
PROJECT = space_surrounded(PROJECT_DEFINITION | RELATIONSHIP | 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? Relationship },
objects.select {|o| o.is_a? Enum },
objects.select {|o| o.is_a? TableGroup }
end
Expand Down
2 changes: 1 addition & 1 deletion lib/dbml/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module DBML
VERSION = "0.1.0"
VERSION = "0.2.0"
end
27 changes: 25 additions & 2 deletions test/dbml_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

PARSERS = DBML::Parser::constants
PARSER_RB = File.read File.join(File.dirname(__FILE__), '../lib/dbml.rb')
TEST_CASE_REGEX = /# (#{PARSERS.join('|')}) ([^:]+): ([^=>]+) => (.*)$/
TEST_CASE_REGEX = /# (#{PARSERS.join('|')}) ([^:]+): ([^=]+) => (.*)$/
TEST_DOC_REGEX = /^\s*# (.*)$/

describe 'Parser' do
Expand Down Expand Up @@ -44,8 +44,31 @@
DBML::Column.new("id", "integer", {:"pk"=>nil, :"unique"=>nil, :"default"=>123.0, :"note"=>"Number"})
], []),
DBML::Table.new("table_name", nil, [], [
], [])
DBML::Column.new("column_name", "column_type", {:"column_settings"=>nil})
], []),
DBML::Table.new("posts", nil, [], [
DBML::Column.new("id", "integer", {:"primary key" => nil}),
DBML::Column.new("user_id", "integer", {:ref => [
DBML::Relationship.new(nil, nil, [], '>', 'users', ['id'], {})
]})
], []),
DBML::Table.new("users", nil, [], [
DBML::Column.new("id", "integer", {:ref => [
DBML::Relationship.new(nil, nil, [], '<', 'posts', ['user_id'], {}),
DBML::Relationship.new(nil, nil, [], '<', 'reviews', ['user_id'], {})
]})
], []),
DBML::Table.new("posts", nil, [], [
DBML::Column.new("id", "integer", {}),
DBML::Column.new("user_id", "integer", {:ref => [
DBML::Relationship.new(nil, nil, [], '>', 'users', ['id'], {})
]})
], []),
], [ # relationships
DBML::Relationship.new(nil, 'merchant_periods', ['merchant_id', 'country_code'], '>', 'merchants', ['id', 'country_code'], {}),
DBML::Relationship.new('name_optional', 'table1', ['column1'], '<', 'table2', ['column2'], {}),
DBML::Relationship.new('name_optional', 'table1', ['column1'], '<', 'table2', ['column2'], {}),
DBML::Relationship.new(nil, 'products', ['merchant_id'], '>', 'merchants', ['id'], {delete: :cascade, update: :'no action'})
], [ # enums
DBML::Enum.new("job_status", [
DBML::EnumChoice.new("created", {:"note"=>"Waiting to be processed"}),
Expand Down

0 comments on commit 13ba82e

Please sign in to comment.