bespoke 0.2.0 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- data/bespoke.gemspec +3 -2
- data/lib/bespoke.rb +14 -0
- data/lib/bespoke/dsl.rb +2 -3
- data/lib/bespoke/export.rb +7 -0
- data/lib/bespoke/export/filter.rb +154 -0
- data/lib/bespoke/export/filter/function_call.rb +61 -0
- data/lib/bespoke/export/filter/identifier.rb +66 -0
- data/lib/bespoke/export/filter/select_template.rb +109 -0
- data/lib/bespoke/export/filter/string_literal.rb +16 -0
- data/lib/bespoke/join.rb +15 -8
- data/lib/bespoke/mustache.rb +17 -0
- data/lib/bespoke/projection.rb +31 -17
- data/lib/bespoke/template.rb +2 -2
- data/lib/bespoke/typed_field.rb +30 -0
- data/lib/bespoke/version.rb +1 -1
- data/spec/fixtures/sif.student_personal.xslt +1 -1
- data/spec/fixtures/sif.xslt.mustache +1 -1
- data/spec/lib/bespoke/export/filter/function_call_spec.rb +72 -0
- data/spec/lib/bespoke/export/filter/identifier_spec.rb +62 -0
- data/spec/lib/bespoke/export/filter/select_template_spec.rb +102 -0
- data/spec/lib/bespoke/export/filter_spec.rb +102 -0
- data/spec/lib/bespoke/export_spec.rb +6 -0
- data/spec/lib/bespoke/join_spec.rb +59 -0
- data/spec/lib/bespoke/projection_spec.rb +94 -0
- data/spec/{template_spec.rb → lib/bespoke/template_spec.rb} +3 -3
- data/spec/lib/bespoke/typed_field_spec.rb +101 -0
- data/spec/{xsltproc_spec.rb → lib/bespoke/xsltproc_spec.rb} +1 -1
- data/spec/spec_helper.rb +16 -3
- data/spec/support/fixture_helpers.rb +24 -0
- metadata +52 -12
- data/spec/join_spec.rb +0 -44
@@ -0,0 +1,16 @@
|
|
1
|
+
module Bespoke
|
2
|
+
class Export
|
3
|
+
class Filter
|
4
|
+
class StringLiteral
|
5
|
+
def self.match?(string)
|
6
|
+
string.start_with?("'") && string.end_with?("'")
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.extract_from(string)
|
10
|
+
unquoted_literal = string[1..-2]
|
11
|
+
Sequel.expr(unquoted_literal)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
data/lib/bespoke/join.rb
CHANGED
@@ -11,7 +11,12 @@ module Bespoke
|
|
11
11
|
|
12
12
|
def database
|
13
13
|
return @database if @database
|
14
|
-
@database = Sequel.sqlite
|
14
|
+
@database = Sequel.sqlite(nil, after_connect: ->(connection) {
|
15
|
+
connection.create_function('concat_ws', -1) {|proxy, *args|
|
16
|
+
separator = args.pop
|
17
|
+
proxy.result = args.compact.join(separator)
|
18
|
+
}
|
19
|
+
})
|
15
20
|
create_tables
|
16
21
|
@database
|
17
22
|
end
|
@@ -27,9 +32,15 @@ module Bespoke
|
|
27
32
|
end
|
28
33
|
end
|
29
34
|
|
30
|
-
def
|
35
|
+
def [](sql)
|
31
36
|
database[sql]
|
32
37
|
end
|
38
|
+
alias_method :query, :[]
|
39
|
+
|
40
|
+
def projection_named(table_name)
|
41
|
+
table_symbol = database.tables.find{|t| t.to_s == table_name.to_s}
|
42
|
+
table_symbol.nil? ? nil : database[table_symbol]
|
43
|
+
end
|
33
44
|
|
34
45
|
protected
|
35
46
|
|
@@ -39,17 +50,13 @@ module Bespoke
|
|
39
50
|
|
40
51
|
def create_table(prj)
|
41
52
|
database.drop_table?(prj.name)
|
53
|
+
prj.ensure_primary_key!
|
42
54
|
database.create_table(prj.name) do
|
43
55
|
prj.fields.each do |field|
|
44
|
-
|
45
|
-
column :ref_id, :string, :primary_key => true
|
46
|
-
else
|
47
|
-
column field.name, field.type
|
48
|
-
end
|
56
|
+
column field.name, field.type, field.column_options
|
49
57
|
end
|
50
58
|
end
|
51
59
|
end
|
52
|
-
|
53
60
|
end
|
54
61
|
end
|
55
62
|
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'mustache'
|
2
|
+
|
3
|
+
if RUBY_VERSION >= '2.0'
|
4
|
+
class Mustache
|
5
|
+
TABLE_FOR_ESCAPE_HTML = {
|
6
|
+
'&' => '&',
|
7
|
+
'"' => '"',
|
8
|
+
'<' => '<',
|
9
|
+
'>' => '>',
|
10
|
+
}
|
11
|
+
REGEXP_FOR_ESCAPE_HTML = Regexp.new("[#{ TABLE_FOR_ESCAPE_HTML.keys.map{|k| Regexp.escape(k) }.join }]")
|
12
|
+
|
13
|
+
def escapeHTML(string)
|
14
|
+
string.gsub(REGEXP_FOR_ESCAPE_HTML, TABLE_FOR_ESCAPE_HTML)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/bespoke/projection.rb
CHANGED
@@ -1,28 +1,20 @@
|
|
1
|
-
|
2
|
-
class TypedField
|
3
|
-
attr_reader :name, :type, :xpath
|
4
|
-
|
5
|
-
# @name The name of the column or field
|
6
|
-
# @type The Sqlite3 type of the data
|
7
|
-
# @xpath The xpath query that retrieves the data from XML
|
8
|
-
def initialize(name, type, xpath)
|
9
|
-
@name = name
|
10
|
-
@type = type
|
11
|
-
@xpath = xpath
|
12
|
-
end
|
13
|
-
end
|
1
|
+
require_relative 'typed_field'
|
14
2
|
|
3
|
+
module Bespoke
|
15
4
|
class Projection
|
16
5
|
attr_reader :name, :fields, :xpath_root
|
17
6
|
|
18
|
-
|
7
|
+
class NoPrimaryKeyError < Bespoke::Error; end
|
8
|
+
|
9
|
+
def initialize(name, xpath_root=nil, &block)
|
19
10
|
@name = name.to_s
|
20
11
|
@xpath_root = xpath_root
|
21
12
|
@fields = []
|
13
|
+
handle_init_block(&block) if block_given?
|
22
14
|
end
|
23
15
|
|
24
|
-
def field(name, type=:string,
|
25
|
-
@fields << TypedField.new(name, type,
|
16
|
+
def field(name, type=:string, options_or_xpath = {})
|
17
|
+
@fields << TypedField.new(name, type, options_or_xpath)
|
26
18
|
end
|
27
19
|
|
28
20
|
def xpath_hash
|
@@ -31,5 +23,27 @@ module Bespoke
|
|
31
23
|
end.flatten(1)
|
32
24
|
Hash[*pairs]
|
33
25
|
end
|
26
|
+
|
27
|
+
def ensure_primary_key!
|
28
|
+
return if @fields.any?(&:primary_key?)
|
29
|
+
|
30
|
+
ref_id = @fields.find{|f| f.name == :ref_id }
|
31
|
+
if ref_id
|
32
|
+
warn 'Implicit primary key of ref_id detected, this feature is deprecated and will be removed soon.'
|
33
|
+
ref_id.make_primary_key!
|
34
|
+
else
|
35
|
+
raise NoPrimaryKeyError, "No primary key specified or implied for projection #{ self.name }"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def handle_init_block(&block)
|
42
|
+
if block.arity == 1
|
43
|
+
block.call(self)
|
44
|
+
else
|
45
|
+
instance_exec(&block)
|
46
|
+
end
|
47
|
+
end
|
34
48
|
end
|
35
|
-
end
|
49
|
+
end
|
data/lib/bespoke/template.rb
CHANGED
@@ -0,0 +1,30 @@
|
|
1
|
+
module Bespoke
|
2
|
+
class TypedField
|
3
|
+
attr_reader :name, :type, :column_options, :xpath
|
4
|
+
|
5
|
+
# @name The name of the column or field
|
6
|
+
# @type The Sqlite3 type of the data
|
7
|
+
# @options_or_xpath The options to be supplied to Sequel when generating the column
|
8
|
+
# along with the xpath used to extract the data from XML
|
9
|
+
def initialize(name, type, options_or_xpath = {})
|
10
|
+
@name = name
|
11
|
+
@type = type
|
12
|
+
if options_or_xpath.is_a? Hash
|
13
|
+
@xpath = options_or_xpath.delete(:xpath)
|
14
|
+
@column_options = options_or_xpath
|
15
|
+
else
|
16
|
+
@xpath = options_or_xpath
|
17
|
+
@column_options = {}
|
18
|
+
warn "Passing a string or array as the xpath is deprecated, please supply an options hash with an xpath key at: #{ caller[1] }"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def make_primary_key!
|
23
|
+
column_options[:primary_key] = true
|
24
|
+
end
|
25
|
+
|
26
|
+
def primary_key?
|
27
|
+
!!column_options[:primary_key]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/bespoke/version.rb
CHANGED
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Bespoke
|
4
|
+
class Export::Filter
|
5
|
+
describe FunctionCall do
|
6
|
+
describe '#function' do
|
7
|
+
it 'must correctly identify the function specified when no args are present' do
|
8
|
+
call = FunctionCall.new('rand()')
|
9
|
+
expect(call.function).to eq 'rand'
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'must correctly identify the function specified when an argument is being passed' do
|
13
|
+
call = FunctionCall.new("foo_bar(0)")
|
14
|
+
expect(call.function).to eq 'foo_bar'
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'must correctly identify the function specified when multiple arguments are being supplied' do
|
18
|
+
call = FunctionCall.new("foo_bar(0, 2, foo)")
|
19
|
+
expect(call.function).to eq 'foo_bar'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#arguments' do
|
24
|
+
it 'must return an empty array when no arguments are specified for the function being called' do
|
25
|
+
call = FunctionCall.new('rand()')
|
26
|
+
expect(call.arguments).to eq []
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'must return an array with a single string when a single argument is supplied' do
|
30
|
+
call = FunctionCall.new("foo_bar(0)")
|
31
|
+
expect(call.arguments).to eq ['0']
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'must return an array with the specified values when multiple values are supplied' do
|
35
|
+
call = FunctionCall.new("foo_bar(0, foo, bar)")
|
36
|
+
expect(call.arguments).to eq %w{0 foo bar}
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'must reutrn the correct values when the whitespace is inconsistent' do
|
40
|
+
call = FunctionCall.new("foo_bar(\t 0, foo,bar )")
|
41
|
+
expect(call.arguments).to eq %w{0 foo bar}
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#extract' do
|
46
|
+
it 'must return a Sequel function node for most inputs' do
|
47
|
+
call = FunctionCall.new("foo_bar(0, foo, bar)")
|
48
|
+
extracted_call = call.extract
|
49
|
+
expect(extracted_call).to be_a Sequel::SQL::Function
|
50
|
+
expect(extracted_call.name).to eq 'FOO_BAR'
|
51
|
+
expect(extracted_call.args).to eq %w{0 foo bar}
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'must specially handle a function named random_str' do
|
55
|
+
call = FunctionCall.new("random_str(4)")
|
56
|
+
extracted_call = call.extract
|
57
|
+
expect(extracted_call).to be_a Sequel::LiteralString
|
58
|
+
expect(extracted_call.to_s).to eq 'LOWER(HEX(RANDOMBLOB(4)))'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe "custom functions" do
|
63
|
+
it "can be created" do
|
64
|
+
call = FunctionCall.new("myfn('hi')", :myfn => ->(args) { "LOWER(#{args.first})" })
|
65
|
+
extracted_call = call.extract
|
66
|
+
expect(extracted_call).to be_a Sequel::LiteralString
|
67
|
+
expect(extracted_call.to_s).to eq "LOWER('hi')"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'bespoke/export/filter/identifier'
|
3
|
+
|
4
|
+
module Bespoke
|
5
|
+
class Export::Filter
|
6
|
+
describe Identifier do
|
7
|
+
describe '#sequel_identifier' do
|
8
|
+
it 'must return an unqualified identifer with the right content for an unqualified column' do
|
9
|
+
identifier = Identifier.new('id')
|
10
|
+
sequel_identifier = identifier.sequel_identifier
|
11
|
+
expect(sequel_identifier).to be_a Sequel::SQL::Identifier
|
12
|
+
expect(sequel_identifier.value).to eq 'id'
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'must return a qualified identifier with the right conent for a qualified column' do
|
16
|
+
identifier = Identifier.new('students.id')
|
17
|
+
sequel_identifier = identifier.sequel_identifier
|
18
|
+
expect(sequel_identifier).to be_a Sequel::SQL::QualifiedIdentifier
|
19
|
+
expect(sequel_identifier.table).to eq 'students'
|
20
|
+
expect(sequel_identifier.column).to eq 'id'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
describe '#parts' do
|
25
|
+
it 'must correctly handle identifiers surrounded by arbitrary whitespace' do
|
26
|
+
identifier = Identifier.new("\t students.id ")
|
27
|
+
expect(identifier.parts).to eq({table: 'students', column: 'id'})
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'must take an unqualified unquoted column identifier and return the correct values' do
|
31
|
+
identifier = Identifier.new('id')
|
32
|
+
expect(identifier.parts).to eq({table: nil, column: 'id'})
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'must take a qualified unquoted column identifier and return the correct values' do
|
36
|
+
identifier = Identifier.new('students.id')
|
37
|
+
expect(identifier.parts).to eq({table: 'students', column: 'id'})
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'must take an unqualified quoted column identifier and return the correct values' do
|
41
|
+
identifier = Identifier.new('"name.first_name"')
|
42
|
+
expect(identifier.parts).to eq({table: nil, column: 'name.first_name'})
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'must take a qualified column identifier with the column name quoted and return the correct values' do
|
46
|
+
identifier = Identifier.new('students."name.first_name"')
|
47
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'must take a qualified column identifier with the table name quoted and return the correct values' do
|
51
|
+
identifier = Identifier.new('"students".name')
|
52
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name'})
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'must take a qualified, fully quoted column identifier and return the correct values' do
|
56
|
+
identifier = Identifier.new('"students"."name.first_name"')
|
57
|
+
expect(identifier.parts).to eq({table: 'students', column: 'name.first_name'})
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module Bespoke
|
4
|
+
class Export::Filter
|
5
|
+
describe SelectTemplate do
|
6
|
+
let(:join) { basic_join }
|
7
|
+
let(:db) { join.database }
|
8
|
+
let(:table) { db[:two] }
|
9
|
+
let(:template) { SelectTemplate.new(:course_id, '') }
|
10
|
+
|
11
|
+
describe '#compile' do
|
12
|
+
let(:result) { template.compile }
|
13
|
+
let(:sql) { table.select(result).sql }
|
14
|
+
|
15
|
+
it 'must build an aliased expression with the correct alias' do
|
16
|
+
expect(result.aliaz).to eq :course_id
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'must interpret a symbol as a column specification' do
|
20
|
+
template.template_string = :id
|
21
|
+
expect(sql).to include %q[`id` AS 'course_id']
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'must interpret a symbol with double underscores as a qualified column specification' do
|
25
|
+
template.template_string = :sections__id
|
26
|
+
expect(sql).to include %q[`sections`.`id` AS 'course_id']
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'must interpret unqualified templates correctly' do
|
30
|
+
template.template_string = '<id>'
|
31
|
+
expect(sql).to include %q[`id` AS 'course_id']
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'must interpret qualified templates correctly' do
|
35
|
+
template.template_string = '<sections.id>'
|
36
|
+
expect(sql).to include %q[`sections`.`id` AS 'course_id']
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'must interpret a template with multiple values as neeing to be caalesced' do
|
40
|
+
template.template_string = '<id | foo>'
|
41
|
+
expect(sql).to include %q[COALESCE(`id`, `foo`) AS 'course_id']
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'must interepret a template in single quotes as a literal value' do
|
45
|
+
template.template_string = "<'foo'>"
|
46
|
+
expect(sql).to include %q['foo' AS 'course_id']
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'must coalesce a identifer and a literal string correctly' do
|
50
|
+
template.template_string = "<id | 'foo'>"
|
51
|
+
expect(sql).to include %q[COALESCE(`id`, 'foo') AS 'course_id']
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'must interepret a default value of random_str(\d+) correctly' do
|
55
|
+
template.template_string = '<courses.enrollment_count | random_str(6)>'
|
56
|
+
expect(sql).to include %q[COALESCE(`courses`.`enrollment_count`, LOWER(HEX(RANDOMBLOB(6)))) AS 'course_id']
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'must interpret a template consisting of a column and a prepended string correctly' do
|
60
|
+
template.template_string = 'course_<courses.id>'
|
61
|
+
expect(sql).to include %q[('course_' || `courses`.`id`) AS 'course_id']
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'must interpret a template consisting of a columna and an appended string correctly' do
|
65
|
+
template.template_string = '<students.student_username>@foo.k12.mo.us'
|
66
|
+
expect(sql).to include %q[(`students`.`student_username` || '@foo.k12.mo.us') AS 'course_id']
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'must interpret complex strings with column templates interpolated inside them' do
|
70
|
+
template.template_string = 'course_<courses.id>_record'
|
71
|
+
expect(sql).to include %q[('course_' || `courses`.`id` || '_record') AS 'course_id']
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'must interpret 2 element joined array selecte template strings correctly' do
|
75
|
+
template.template_string = '<section_id>-<term_id>'
|
76
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, '-') AS 'course_id']
|
77
|
+
end
|
78
|
+
|
79
|
+
it 'must interpret joined array select template strings correctly' do
|
80
|
+
template.template_string = '<section_id>-<term_id>-<staff_id>'
|
81
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '-') AS 'course_id']
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'must extract the correct separator from joined array template strings' do
|
85
|
+
template.template_string = '<section_id>=+=<term_id>=+=<staff_id>'
|
86
|
+
expect(sql).to include %q[concat_ws(`section_id`, `term_id`, `staff_id`, '=+=') AS 'course_id']
|
87
|
+
end
|
88
|
+
|
89
|
+
context "with custom function" do
|
90
|
+
let(:template) {
|
91
|
+
SelectTemplate.new(:course_id, '', :myfn => ->(args) { "MYFN()" })
|
92
|
+
}
|
93
|
+
|
94
|
+
it 'does it' do
|
95
|
+
template.template_string = '<myfn(5)>'
|
96
|
+
expect(sql).to include %q[MYFN()]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|