summaryrefslogtreecommitdiffstats
path: root/acf2/model
diff options
context:
space:
mode:
Diffstat (limited to 'acf2/model')
-rw-r--r--acf2/model/aaa.lua87
-rw-r--r--acf2/model/combination.lua50
-rw-r--r--acf2/model/field.lua241
-rw-r--r--acf2/model/init.lua205
-rw-r--r--acf2/model/model.lua204
-rw-r--r--acf2/model/net.lua96
-rw-r--r--acf2/model/node.lua275
-rw-r--r--acf2/model/permission.lua22
-rw-r--r--acf2/model/root.lua91
-rw-r--r--acf2/model/set.lua47
10 files changed, 1318 insertions, 0 deletions
diff --git a/acf2/model/aaa.lua b/acf2/model/aaa.lua
new file mode 100644
index 0000000..c8a8328
--- /dev/null
+++ b/acf2/model/aaa.lua
@@ -0,0 +1,87 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = require('acf2.model')
+local object = require('acf2.object')
+
+local digest = require('crypto').digest
+
+
+Role = M.new()
+Role.permissions = M.Set{type=M.Reference{scope='../../../permissions'}}
+
+
+local function hash_password(algorithm, salt, password)
+ return algorithm..'$'..salt..'$'..digest(algorithm, salt..password)
+end
+
+local hash_pattern = '^(%w+)%$(%w+)%$%x+$'
+
+
+local Password = object.class(M.String)
+
+function Password:_validate(context, value)
+ value = object.super(self, M.String):_validate(context, value)
+ if not value or value:find(hash_pattern) then return value end
+
+ local salt = ''
+ for i = 1,12 do
+ local c = math.random(48, 109)
+ if c > 57 then c = c + 7 end
+ if c > 90 then c = c + 6 end
+ salt = salt..string.char(c)
+ end
+ return hash_password('sha256', salt, value)
+end
+
+
+User = M.new()
+User.password = Password
+User['real-name'] = M.String
+User.superuser = M.Boolean{default=false}
+User.roles = M.Set{type=M.Reference{scope='../../../roles'}}
+
+function User:check_password(password)
+ if not self.password then return false end
+ local _, _, algorithm, salt = self.password:find(hash_pattern)
+ if not salt then return false end
+ return hash_password(algorithm, salt, password) == self.password
+end
+
+function User:check_permission(permission)
+ -- TODO audit trail
+ print('check permission', permission)
+
+ if self.superuser then return true end
+
+ assert(getmetatable(self).txn:fetch('/auth/permissions')[permission])
+
+ for _, role in M.node.pairs(self.roles, true) do
+ for _, p in M.node.pairs(role.permissions, true) do
+ if p == permission then return true end
+ end
+ end
+ return false
+end
+
+
+Authentication = M.new()
+Authentication.users = M.Collection{type=User}
+Authentication.roles = M.Collection{type=Role}
+Authentication.permissions = M.Set{
+ type=M.String,
+ addr='/volatile/aaa/permissions'
+}
+
+M.register(
+ 'auth',
+ Authentication,
+ {
+ addr='/json'..require('posix').getcwd()..'/config/aaa.json',
+ ui_name='Authentication'
+ }
+)
+
+M.permission.defaults('/auth')
diff --git a/acf2/model/combination.lua b/acf2/model/combination.lua
new file mode 100644
index 0000000..28c1570
--- /dev/null
+++ b/acf2/model/combination.lua
@@ -0,0 +1,50 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('acf2.error')
+local raise = err.raise
+
+local fld = require('acf2.model.field')
+local String = fld.String
+
+local to_field = require('acf2.model.model').to_field
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+
+local stringy = require('stringy')
+
+
+M.Range = class(String)
+
+function M.Range:init(params)
+ super(self, M.Range):init(params)
+ if not self.type then self.type = fld.Integer end
+end
+
+function M.Range:validate(context, value)
+ local comps = stringy.split(value, '-')
+ if #comps > 2 then raise(context.path, 'Invalid range') end
+ for _, v in ipairs(comps) do to_field(self.type):_validate(context, v) end
+end
+
+
+M.Union = class(String)
+
+function M.Union:validate(context, value)
+ super(self, M.Union):validate(context, value)
+ for _, tpe in ipairs(self.types) do
+ local field = to_field(tpe)
+ if err.call(field.validate, field, context, value) then return end
+ end
+ raise(context.path, self.error or 'Invalid value')
+end
+
+
+return M
diff --git a/acf2/model/field.lua b/acf2/model/field.lua
new file mode 100644
index 0000000..6cbfa96
--- /dev/null
+++ b/acf2/model/field.lua
@@ -0,0 +1,241 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local err = require('acf2.error')
+local raise = err.raise
+
+local node = require('acf2.model.node')
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+local util = require('acf2.util')
+
+
+local function contains(list, value)
+ for k, v in ipairs(list) do if v == value then return true end end
+ return false
+end
+
+M.Member = class()
+
+function M.Member:init(params)
+ for k, v in pairs(params or {}) do
+ if self[k] == nil then self[k] = v end
+ end
+end
+
+function M.Member:auto_ui_name(name)
+ if not name then return end
+ return (name:sub(1, 1):upper()..name:sub(2)):gsub('-', ' ')
+end
+
+function M.Member:meta(context)
+ return {
+ name=self.name,
+ description=self.description,
+ ['ui-name']=self.ui_name or self:auto_ui_name(self.name)
+ }
+end
+
+
+M.Field = class(M.Member)
+
+function M.Field:init(params)
+ super(self, M.Field):init(params)
+
+ if self.choice and not self['ui-choice'] then
+ self['ui-choice'] = util.map(
+ function(name) return self:auto_ui_name(name) end,
+ self.choice
+ )
+ end
+
+ if not self.widget then
+ self.widget = self.choice and 'combobox' or 'field'
+ end
+end
+
+function M.Field:meta(context)
+ assert(self.dtype)
+ local res = super(self, M.Field):meta(context)
+
+ res.type = self.dtype
+ res.required = self.required
+ res.default = self.default
+ res.choice = self.choice
+ res.widget = self.widget
+ res['ui-choice'] = self['ui-choice']
+
+ return res
+end
+
+function M.Field:topology(context)
+ return {
+ {path=context.path, addr=context.addr, type=self.dtype}
+ }
+end
+
+function M.Field:load(context)
+ if not context.txn then return setmetatable({}, context) end
+ local value = context.txn:get(context.addr)
+ if value == nil then return self.default end
+ return value
+end
+
+function M.Field:_validate(context, value)
+ if self.required and value == nil then
+ raise(context.path, 'Required value not set')
+ end
+ if self.choice and value ~= nil and not contains(self.choice, value) then
+ raise(context.path, 'Invalid value')
+ end
+ if value ~= nil then self:validate(context, value) end
+ return value
+end
+
+function M.Field:validate(context, value) end
+
+function M.Field:save(context, value)
+ context.txn:set(context.addr, self:_validate(context, value))
+end
+
+function M.Field:validate_saved(context)
+ self:save(context, self:load(context))
+end
+
+
+local Primitive = class(M.Field)
+
+function Primitive:validate(context, value)
+ local t = self.dtype
+ if type(value) ~= t then raise(context.path, 'Not a '..t) end
+end
+
+
+M.String = class(Primitive)
+
+function M.String:init(params)
+ super(self, M.String):init(params)
+ self.dtype = 'string'
+end
+
+function M.String:validate(context, value)
+ super(self, M.String):validate(context, value)
+ if self['max-length'] and value:len() > self['max-length'] then
+ raise(context.path, 'Maximum length exceeded')
+ end
+ if self.pattern and not value:match('^'..self.pattern..'$') then
+ raise(context.path, 'Invalid value')
+ end
+end
+
+function M.String:meta(context)
+ local res = super(self, M.String):meta(context)
+ res['max-length'] = self['max-length']
+ return res
+end
+
+
+M.Number = class(Primitive)
+
+function M.Number:init(params)
+ super(self, M.Number):init(params)
+ self.dtype = 'number'
+end
+
+function M.Number:_validate(context, value)
+ return super(self, M.Number):_validate(
+ context,
+ value and tonumber(value) or value
+ )
+end
+
+
+M.Integer = class(M.Number)
+
+function M.Integer:validate(context, value)
+ super(self, M.Integer):validate(context, value)
+ if math.floor(value) ~= value then raise(context.path, 'Not an integer') end
+end
+
+
+M.Boolean = class(Primitive)
+
+function M.Boolean:init(params)
+ super(self, M.Boolean):init(params)
+ self.dtype = 'boolean'
+ self.widget = self.dtype
+end
+
+
+M.TreeNode = class(M.Field)
+
+function M.TreeNode:init(params)
+ if not params.widget then params.widget = 'link' end
+ super(self, M.TreeNode):init(params)
+end
+
+function M.TreeNode:topology(context)
+ local res = super(self, M.TreeNode):topology(context)
+ res[1].type = 'table'
+ util.extend(res, node.topology(self:load(context, {create=true})))
+ return res
+end
+
+function M.TreeNode:load(context, options)
+ if context.txn and not (
+ (
+ options and options.create
+ ) or self.create or context.txn:get(context.addr)
+ ) then return end
+ return self.itype(context, self.iparams)
+end
+
+function M.TreeNode:save(context, value)
+ local path = context.path
+
+ if value == path then return end
+ if type(value) == 'string' then value = context.txn:fetch(value) end
+ if object.isinstance(value, node.TreeNode) and node.path(value) == path then
+ return
+ end
+
+ context.txn:set(context.addr)
+
+ if value then
+ if type(value) ~= 'table' then
+ raise(path, 'Cannot assign primitive value')
+ end
+
+ context.txn:set(context.addr, {})
+ local new = self:load(context, {create=true})
+
+ local errors = err.ErrorDict()
+ for k, v in node.pairs(value) do
+ errors:collect(self.save_member, new, k, v)
+ end
+ errors:raise()
+ end
+end
+
+function M.TreeNode.save_member(node, k, v) node[k] = v end
+
+
+M.Model = class(M.TreeNode)
+
+function M.Model:init(params)
+ super(self, M.Model):init(params)
+
+ assert(self.model)
+ self.itype = self.model
+ self.dtype = 'model'
+end
+
+
+return M
diff --git a/acf2/model/init.lua b/acf2/model/init.lua
new file mode 100644
index 0000000..51cdbe8
--- /dev/null
+++ b/acf2/model/init.lua
@@ -0,0 +1,205 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+M.error = require('acf2.error')
+local raise = M.error.raise
+
+local combination = require('acf2.model.combination')
+M.Union = combination.Union
+M.Range = combination.Range
+
+local fld = require('acf2.model.field')
+local Field = fld.Field
+M.Boolean = fld.Boolean
+M.Integer = fld.Integer
+M.Number = fld.Number
+M.String = fld.String
+
+local model = require('acf2.model.model')
+M.Action = model.Action
+M.new = model.new
+local to_field = model.to_field
+
+M.net = require('acf2.model.net')
+
+local node = require('acf2.model.node')
+M.node = {}
+for _, m in ipairs{
+ 'List',
+ 'Set',
+ 'TreeNode',
+ 'has_permission',
+ 'insert',
+ 'meta',
+ 'mmeta',
+ 'path',
+ 'pairs',
+ 'ipairs'
+} do M.node[m] = node[m] end
+
+M.permission = require('acf2.model.permission')
+M.register = require('acf2.model.root').register
+M.node.Set = require('acf2.model.set').Set
+
+local object = require('acf2.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+local pth = require('acf2.path')
+local map = require('acf2.util').map
+
+
+local stringy = require('stringy')
+
+
+M.Reference = class(Field)
+
+function M.Reference:init(params)
+ if not params.widget then params.widget = 'reference' end
+ super(self, M.Reference):init(params)
+ self.dtype = 'reference'
+ if not self.scope then self.scope = '/' end
+end
+
+function M.Reference:topology(context)
+ local res = super(self, M.Reference):topology(context)
+ res[1].scope = self.scope
+ return res
+end
+
+function M.Reference:abs_scope(context)
+ return pth.to_absolute(self.scope, node.path(context.parent))
+end
+
+function M.Reference:meta(context)
+ local res = super(self, M.Reference):meta(context)
+ res.scope = self:abs_scope(context)
+ return res
+end
+
+function M.Reference:follow(context, value)
+ return context.txn:fetch(pth.rawjoin(self:abs_scope(context), value))
+end
+
+function M.Reference:load(context, options)
+ local ref = super(self, M.Reference):load(context)
+ return (
+ (not options or options.dereference ~= false) and context.txn and ref
+ ) and self:follow(context, ref) or ref
+end
+
+function M.Reference:_validate(context, value)
+ super(self, M.Reference):_validate(context, value)
+
+ if value == nil then return end
+
+ if isinstance(value, node.TreeNode) then value = node.path(value) end
+
+ local path = context.path
+ if type(value) ~= 'string' then raise(path, 'Path name must be string') end
+
+ if pth.is_absolute(value) then
+ local scope = self:abs_scope(context)
+ local prefix = scope..'/'
+ if not stringy.startswith(value, prefix) then
+ raise(path, 'Reference out of scope ('..scope..')')
+ end
+ value = value:sub(prefix:len() + 1, -1)
+ end
+
+ -- assume one-level ref for now
+ if #pth.split(value) > 1 then
+ raise(path, 'Subtree references not yet supported')
+ end
+
+ -- TODO check instance type
+ M.error.relabel(path, self.follow, self, context, value)
+
+ return value
+end
+
+function M.Reference:deleted(context, addr)
+ local target = self:load(context)
+ if target and node.addr(target) == addr then
+ -- TODO raise error for the target object
+ raise(context.path, 'Refers to '..addr)
+ end
+end
+
+
+M.Model = fld.Model
+
+
+M.Collection = class(fld.TreeNode)
+
+function M.Collection:init(params, itype)
+ if params.create == nil then params.create = true end
+ super(self, M.Collection):init(params)
+
+ assert(self.type)
+ self.itype = itype or node.Collection
+ self.iparams = {
+ destroy=self.destroy,
+ layout=self.layout,
+ required=self.required,
+ ui_member=self.ui_member
+ }
+
+ self.dtype = 'collection'
+end
+
+function M.Collection:auto_ui_name(name)
+ if not name then return end
+ if name:sub(-1, -1) ~= 's' then name = name..'s' end
+ return super(self, M.Collection):auto_ui_name(name)
+end
+
+function M.Collection:load(context, options)
+ if not self.iparams.field then self.iparams.field = to_field(self.type) end
+ return super(self, M.Collection):load(context, options)
+end
+
+
+M.List = class(M.Collection)
+function M.List:init(params) super(self, M.List):init(params, node.List) end
+
+
+M.Set = class(M.Collection)
+function M.Set:init(params)
+ if not params.widget and isinstance(params.type, M.Reference) then
+ params.widget = 'checkboxes'
+ end
+ super(self, M.Set):init(params, M.node.Set)
+end
+function M.Set.save_member(tn, k, v) node.insert(tn, v) end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(params)
+ params.type = M.Mixed
+ super(self, M.Mixed):init(params, node.Mixed)
+ self.pfield = Field()
+end
+
+function M.Mixed:topology(context) return {} end
+
+function M.Mixed:load(context)
+ local value = self.pfield:load(context)
+ if type(value) == 'table' then return super(self, M.Mixed):load(context) end
+ return value
+end
+
+function M.Mixed:save(context, value)
+ if type(value) == 'table' then super(self, M.Mixed):save(context, value)
+ else self.pfield:save(context, value) end
+end
+
+
+return M
diff --git a/acf2/model/model.lua b/acf2/model/model.lua
new file mode 100644
index 0000000..dc506b3
--- /dev/null
+++ b/acf2/model/model.lua
@@ -0,0 +1,204 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+
+local fld = require('acf2.model.field')
+local Field = fld.Field
+local Member = fld.Member
+
+local node = require('acf2.model.node')
+local BoundMember = node.BoundMember
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+local isinstance = object.isinstance
+
+local pth = require('acf2.path')
+local util = require('acf2.util')
+
+
+local function to_member(obj, params)
+ if not params then params = {} end
+ if object.issubclass(obj, M.Model) then
+ params.model = obj
+ return fld.Model(params)
+ end
+ local res = getmetatable(obj).class and obj or obj(params)
+ assert(isinstance(res, Member))
+ return res
+end
+
+function M.to_field(obj, params)
+ local res = to_member(obj, params)
+ assert(isinstance(res, Field))
+ return res
+end
+
+
+M.Action = class(Member)
+
+function M.Action:init(params)
+ super(self, M.Action):init(params)
+
+ if not self.func then error('Function not defined for action') end
+
+ if self.field then
+ assert(type(self.field) == 'table')
+ self.field = M.to_field(self.field)
+ self.field.addr = '/null/action'
+ end
+
+ getmetatable(self).__newindex = function(t, k, v)
+ assert(k == 'name')
+ rawset(t, k, v)
+ if t.field then t.field.name = v end
+ end
+end
+
+function M.Action:meta(context)
+ local res = super(self, M.Action):meta(context)
+ if self.field then res.arg = self.field:meta(context) end
+ return res
+end
+
+
+function M.new(base)
+ if not base then base = M.Model end
+
+ local res = class(base)
+ res.members = base == node.TreeNode and {} or util.copy(base.members)
+
+ local mt = util.copy(getmetatable(res))
+
+ function mt.__index(t, k) return base[k] end
+
+ function mt.__newindex(t, k, v)
+ assert(v)
+
+ local override = t[k]
+ if type(v) == 'table' then v = to_member(v) end
+
+ rawset(t, k, v)
+
+ if isinstance(v, Member) then
+ v.name = k
+ if not override then table.insert(t.members, k) end
+ end
+ end
+
+ setmetatable(res, mt)
+
+ if isinstance(base, M.Model) then util.setdefaults(res, base) end
+
+ return res
+end
+
+M.Model = M.new(node.TreeNode)
+
+function M.Model:init(context)
+ super(self, M.Model):init(context)
+
+ local mt = getmetatable(self)
+
+ function mt.member(name, loose, tpe)
+ local m = mt.class[name]
+
+ if not tpe then tpe = Member end
+ if not isinstance(m, tpe) then m = nil end
+
+ if m == nil then
+ if loose then return end
+ raise(mt.path, 'Does not exist: '..name)
+ end
+
+ return BoundMember(self, name, m)
+ end
+
+ local function _members(tpe)
+ local res = {}
+ for _, name in ipairs(self.members) do
+ local m = mt.member(name, true, tpe)
+ if m then table.insert(res, m) end
+ end
+ return res
+ end
+
+ function mt.topology()
+ local res = {}
+ for _, f in ipairs(_members(Field)) do util.extend(res, f:topology()) end
+ return res
+ end
+
+ function mt.load(k, options)
+ local v = mt.class[k]
+ local create = options and options.create
+
+ if isinstance(v, Field) then
+ v = BoundMember(self, k, v)
+ if v.compute then return v:compute() end
+ return v:load{create=create}
+ end
+
+ assert(mt.txn)
+
+ if isinstance(v, M.Action) then
+ local f = v.field and BoundMember(self, k, v.field)
+ if create then return f and f:load{create=true} end
+
+ return function(var)
+ if f then f:save(var)
+ elseif var ~= nil then
+ raise(
+ pth.join(mt.path, v.name),
+ 'Action does not accept an input argument'
+ )
+ end
+ local res = v.func(self, f and f:load())
+ if f then mt.txn:set(v.field.addr) end
+ return res
+ end
+ end
+
+ return v
+ end
+
+ if not mt.txn then return end
+
+
+ function mt.mmeta(name) return mt.member(name):meta() end
+
+ function mt.save(k, v) return mt.member(k, false, Field):save(v) end
+
+ local function tmeta(tpe)
+ return util.map(function(m) return m:meta() end, _members(tpe))
+ end
+
+ mt.meta.type = 'model'
+ mt.meta.fields = tmeta(Field)
+ mt.meta.actions = tmeta(M.Action)
+
+ function mt.members()
+ return util.map(function(f) return f.name end, mt.meta.fields)
+ end
+
+ function mt.validate()
+ for _, f in ipairs(_members(Field)) do
+ if not f.compute then f:validate_saved() end
+ end
+ end
+
+ if self.has_permission then
+ function mt.has_permission(user, permission)
+ return self:has_permission(user, permission)
+ end
+ end
+end
+
+
+return M
diff --git a/acf2/model/net.lua b/acf2/model/net.lua
new file mode 100644
index 0000000..27ab262
--- /dev/null
+++ b/acf2/model/net.lua
@@ -0,0 +1,96 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+local Union = require('acf2.model.combination').Union
+
+local fld = require('acf2.model.field')
+local String = fld.String
+
+local object = require('acf2.object')
+local class = object.class
+local super = object.super
+
+local update = require('acf2.util').update
+
+
+local stringy = require('stringy')
+
+
+M.IPv4Address = class(String)
+
+function M.IPv4Address:validate(context, value)
+ super(self, M.IPv4Address):validate(context, value)
+ local function test(...)
+ if #{...} ~= 4 then return true end
+ for _, octet in ipairs{...} do
+ if tonumber(octet) > 255 then return true end
+ end
+ end
+ if test(value:match('^(%d+)%.(%d+)%.(%d+)%.(%d+)$')) then
+ raise(context.path, 'Invalid IPv4 address')
+ end
+end
+
+
+M.IPv6Address = class(String)
+
+function M.IPv6Address:validate(context, value)
+ super(self, M.IPv6Address):validate(context, value)
+
+ local function invalid() raise(context.path, 'Invalid IPv6 address') end
+
+ if value == '' then invalid() end
+
+ local comps = stringy.split(value, ':')
+ if #comps < 3 then invalid() end
+
+ local function collapse(i, ofs)
+ if comps[i] > '' then return end
+ if comps[i + ofs] > '' then invalid() end
+ table.remove(comps, i)
+ end
+ collapse(1, 1)
+ collapse(#comps, -1)
+ if #comps > 8 then invalid() end
+
+ local short = false
+ for _, comp in ipairs(comps) do
+ if comp == '' then
+ if short then invalid() end
+ short = true
+ elseif not comp:match('^%x%x?%x?%x?$') then invalid() end
+ end
+ if (
+ short and #comps == 3 and comps[2] == ''
+ ) or (not short and #comps < 8) then
+ invalid()
+ end
+end
+
+
+M.IPAddress = class(Union)
+
+function M.IPAddress:init(params)
+ super(self, M.IPAddress):init(
+ update(
+ params,
+ {types={M.IPv4Address, M.IPv6Address}, error='Invalid IP address'}
+ )
+ )
+end
+
+
+M.Port = class(fld.Integer)
+
+function M.Port:validate(context, value)
+ super(self, M.Port):validate(context, value)
+ if value < 0 or value > 65535 then raise(context.path, 'Invalid port') end
+end
+
+
+return M
diff --git a/acf2/model/node.lua b/acf2/model/node.lua
new file mode 100644
index 0000000..85fc416
--- /dev/null
+++ b/acf2/model/node.lua
@@ -0,0 +1,275 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local raise = require('acf2.error').raise
+
+local object = require('acf2.object')
+local class = object.class
+local isinstance = object.isinstance
+local super = object.super
+
+local pth = require('acf2.path')
+local util = require('acf2.util')
+
+
+M.BoundMember = class()
+
+function M.BoundMember:init(parent, name, field)
+ local pmt = getmetatable(parent)
+ local mt = {}
+
+ function mt.__index(t, k)
+ local member = field[k]
+ if type(member) ~= 'function' then return member end
+ return function(self, ...)
+ return member(
+ field,
+ {
+ txn=pmt.txn,
+ parent=parent,
+ path=pth.join(pmt.path, name),
+ addr=pth.to_absolute(
+ field.addr or pth.escape(name), pmt.addr
+ )
+ },
+ ...
+ )
+ end
+ end
+
+ setmetatable(self, mt)
+end
+
+
+M.TreeNode = class()
+
+function M.TreeNode:init(context)
+ local mt = getmetatable(self)
+ util.update(mt, context)
+
+ mt.dereference = true
+ mt.meta = {}
+ function mt.get(k, create) return mt.load(k, {create=create}) end
+
+ if not mt.txn then return end
+
+ if mt.parent then
+ mt.meta['ui-name'] = getmetatable(mt.parent).mmeta(
+ pth.name(mt.path)
+ )['ui-name']
+ end
+
+ function mt.save(k, v) rawset(self, k, v) end
+ function mt.__index(t, k) return mt.get(k) end
+ function mt.__newindex(t, k, v) mt.save(k, v) end
+
+ function mt.has_permission(user, permission)
+ local p = permission..mt.path
+ if mt.txn:fetch('/auth/permissions')[p] then
+ return user:check_permission(p)
+ end
+
+ if ({create=true, delete=true})[permission] then
+ permission = 'modify'
+ end
+ return M.has_permission(mt.parent, user, permission)
+ end
+
+ mt.txn.validable[mt.path] = mt.addr
+end
+
+function M.TreeNode:fetch(path, create)
+ if type(path) == 'string' then path = pth.split(path) end
+
+ if #path == 0 then return self end
+
+ local mt = getmetatable(self)
+ local name = path[1]
+ if not mt.member(name) then
+ raise(mt.path, 'Member does not exist: '..name)
+ end
+
+ local next = mt.get(name, create)
+ if next == nil and (not create or #path > 1) then
+ raise(mt.path, 'Subordinate does not exist: '..name)
+ end
+
+ table.remove(path, 1)
+ if #path > 0 and type(next) ~= 'table' then
+ raise(pth.join(mt.path, name), 'Is a primitive value')
+ end
+
+ return M.TreeNode.fetch(next, path, create)
+end
+
+function M.TreeNode:search_refs(path)
+ if type(path) == 'string' then path = pth.split(path) end
+
+ if #path == 0 then return {} end
+
+ local mt = getmetatable(self)
+ local name = path[1]
+ table.remove(path, 1)
+
+ local function collect(name)
+ local next = mt.load(name)
+ if not next then return {} end
+
+ local member = mt.member(name)
+ if member.deleted then return {member} end
+
+ return isinstance(next, M.TreeNode) and M.TreeNode.search_refs(
+ next, path
+ ) or {}
+ end
+
+ if name == pth.wildcard then
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ util.extend(res, collect(member))
+ end
+ return res
+ end
+
+ return collect(name)
+end
+
+
+M.Collection = class(M.TreeNode)
+
+function M.Collection:init(context, params)
+ super(self, M.Collection):init(context)
+
+ self.init = nil
+ self.fetch = nil
+ self.search_refs = nil
+
+ local mt = getmetatable(self)
+ local field = M.BoundMember(self, pth.wildcard, params.field)
+
+ function mt.topology() return field:topology() end
+
+ function mt.member(name)
+ return M.BoundMember(self, name, params.field)
+ end
+
+ function mt.load(k, options) return mt.member(k):load(options) end
+
+ if not mt.txn then return end
+
+ mt.meta.type = 'collection'
+ mt.meta.members = field:meta()
+ mt.meta['ui-member'] = params.ui_member or mt.meta['ui-name']:gsub('s$', '')
+ mt.meta.widget = params.layout
+
+ function mt.mmeta(name)
+ local res = util.copy(mt.meta.members)
+ if name ~= pth.wildcard then
+ res['ui-name'] = mt.meta['ui-member']..' '..name
+ end
+ return res
+ end
+
+ function mt.members() return mt.txn:get(mt.addr) or {} end
+
+ function mt.validate()
+ if #mt.members() > 0 then return end
+ if params.required then raise(mt.path, 'Collection cannot be empty') end
+ if params.destroy then
+ mt.txn:set(mt.addr)
+ validate(mt.parent)
+ end
+ end
+
+ function mt.save(k, v) mt.member(k):save(v) end
+end
+
+
+M.List = class(M.Collection)
+
+function M.List:init(context, params)
+ super(self, M.List):init(context, params)
+
+ local mt = getmetatable(self)
+ mt.meta.type = 'list'
+
+ local save = mt.save
+ function mt.save(k, v)
+ assert(type(k) == 'number')
+ if v == nil then
+ local len = #mt.members()
+ while k < len do
+ mt.save(k, mt.load(k + 1, {dereference=false}))
+ k = k + 1
+ end
+ end
+ save(k, v)
+ end
+
+ function mt.insert(v, i)
+ local len = #mt.members()
+ if not i then i = len + 1 end
+ for j = len,i,-1 do mt.save(j + 1, mt.load(j, {dereference=false})) end
+ mt.save(i, v)
+ end
+end
+
+
+-- experimental
+M.Mixed = class(M.Collection)
+
+function M.Mixed:init(context, params)
+ super(self, M.Mixed):init(context, params)
+
+ -- TODO dynamic meta: list non-leaf children
+ local mt = getmetatable(self)
+ mt.meta = {type='mixed', ['ui-name']=mt.path}
+ function mt.mmeta(name)
+ return {type='mixed', ['ui-name']=pth.join(mt.path, name)}
+ end
+end
+
+
+local function meta_func(attr)
+ return function(node, ...)
+ local res = getmetatable(node)[attr]
+ if type(res) == 'function' then return res(...) end
+ return res
+ end
+end
+
+for _, mf in ipairs{
+ 'addr', 'has_permission', 'insert', 'meta', 'mmeta', 'path', 'topology'
+} do M[mf] = meta_func(mf) end
+
+
+function M.pairs(tbl, dereference)
+ if not isinstance(tbl, M.TreeNode) then return pairs(tbl) end
+
+ local mt = getmetatable(tbl)
+ if dereference == nil then dereference = mt.dereference end
+
+ local res = {}
+ for _, member in ipairs(mt.members()) do
+ res[member] = mt.load(member, {dereference=dereference})
+ end
+ return pairs(res)
+end
+
+local function _ipairs(mt, i)
+ i = i + 1
+ local v = mt.load(i)
+ if v == nil then return end
+ return i, v
+end
+function M.ipairs(tbl)
+ if not isinstance(tbl, M.TreeNode) then return ipairs(tbl) end
+ return _ipairs, getmetatable(tbl), 0
+end
+
+
+return M
diff --git a/acf2/model/permission.lua b/acf2/model/permission.lua
new file mode 100644
index 0000000..71fa1a5
--- /dev/null
+++ b/acf2/model/permission.lua
@@ -0,0 +1,22 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local insert = require('acf2.model.node').insert
+local start_txn = require('acf2.transaction')
+
+function M.define(path, ...)
+ local txn = start_txn()
+ local db = txn:fetch('/auth/permissions')
+ for _, permission in ipairs{...} do insert(db, permission..path) end
+ txn:commit()
+end
+
+function M.defaults(path)
+ M.define(path, 'read', 'create', 'modify', 'delete')
+end
+
+return M
diff --git a/acf2/model/root.lua b/acf2/model/root.lua
new file mode 100644
index 0000000..61debec
--- /dev/null
+++ b/acf2/model/root.lua
@@ -0,0 +1,91 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local model = require('acf2.model.model')
+local node = require('acf2.model.node')
+local object = require('acf2.object')
+local pth = require('acf2.path')
+
+local util = require('acf2.util')
+local setdefault = util.setdefault
+
+
+M.RootModel = model.new()
+
+function M.RootModel:init(txn)
+ object.super(self, M.RootModel):init{txn=txn, path='/', addr='/null/root'}
+end
+
+function M.RootModel:has_permission(user, permission)
+ return permission == 'read'
+end
+
+function M.RootModel:meta(path)
+ local obj = self:fetch(path, true)
+ if object.isinstance(obj, node.TreeNode) then return node.meta(obj) end
+ return node.mmeta(self:fetch(pth.parent(path), true), pth.name(path))
+end
+
+
+local _topology = {}
+local order = 0
+
+function M.topology(addr, create)
+ local top = _topology
+ if type(addr) == 'table' then addr = util.copy(addr)
+ else addr = pth.split(addr) end
+
+ local function defaults(top)
+ return util.setdefaults(top, {members={}, paths={}, referrers={}})
+ end
+
+ while #addr > 0 do
+ if create then
+ top = setdefault(defaults(top).members, addr[1], {})
+ else
+ top = top.members[addr[1]] or top.members[pth.wildcard]
+ if not top then return end
+ end
+ table.remove(addr, 1)
+ end
+
+ return defaults(top)
+end
+
+function M.register(name, field, params)
+ if not params then params = {} end
+ params.create = true
+ M.RootModel[name] = model.to_field(field, params)
+
+ local root = M.RootModel()
+
+ for _, record in ipairs(node.topology(root:fetch(name))) do
+ local top = M.topology(record.addr, true)
+
+ setdefault(top, 'order', order)
+ order = order + 1
+
+ local function set(k, v)
+ setdefault(top, k, v)
+ assert(top[k] == v)
+ end
+
+ set('type', record.type)
+ table.insert(top.paths, record.path)
+
+ if record.scope then
+ local scope = node.addr(
+ root:fetch(pth.to_absolute(record.scope, pth.parent(record.path)))
+ )
+ set('scope', scope)
+ table.insert(M.topology(scope, true).referrers, record.path)
+ end
+ end
+end
+
+
+return M
diff --git a/acf2/model/set.lua b/acf2/model/set.lua
new file mode 100644
index 0000000..215056e
--- /dev/null
+++ b/acf2/model/set.lua
@@ -0,0 +1,47 @@
+--[[
+Copyright (c) 2012-2013 Kaarle Ritvanen
+See LICENSE file for license details
+--]]
+
+local M = {}
+
+local TreeNode = require('acf2.model.field').TreeNode
+local npairs = require('acf2.model.node').pairs
+local object = require('acf2.object')
+
+
+M.Set = object.class(require('acf2.model.node').List)
+
+function M.Set:init(context, params)
+ assert(not object.isinstance(params.field, TreeNode))
+ object.super(self, M.Set):init(context, params)
+
+ local function find(value)
+ for i, member in npairs(self) do
+ if member == value then return i end
+ end
+ end
+
+ local mt = getmetatable(self)
+ mt.dereference = false
+ mt.meta.type = 'set'
+
+ function mt.get(k, create)
+ local i = find(k)
+ if i then return mt.load(i) end
+ if create then return k end
+ end
+
+ function mt.__newindex(t, k, v)
+ assert(v == nil)
+ local i = find(k)
+ if not i then return end
+ mt.save(i, nil)
+ end
+
+ local insert = mt.insert
+ function mt.insert(v) if not find(v) then insert(v) end end
+end
+
+
+return M