diff --git a/server/AyaNova/biz/AyaType.cs b/server/AyaNova/biz/AyaType.cs index 722b37d0..229876fc 100644 --- a/server/AyaNova/biz/AyaType.cs +++ b/server/AyaNova/biz/AyaType.cs @@ -26,7 +26,9 @@ namespace AyaNova.Biz TrialSeeder = 11, Metrics = 12, Locale = 13, - UserOptions=14 + UserOptions=14, + TagGroup = 15, + TagGroupMap = 16 } diff --git a/server/AyaNova/biz/BizRoles.cs b/server/AyaNova/biz/BizRoles.cs index 56b833a9..5b6beb94 100644 --- a/server/AyaNova/biz/BizRoles.cs +++ b/server/AyaNova/biz/BizRoles.cs @@ -112,6 +112,26 @@ namespace AyaNova.Biz ReadFullRecord = AuthorizationRoles.AnyRole }); + //////////////////////////////////////////////////////////// + //TAGGROUP - MIRROR TAGS + //Full roles can make new tags and can edit or delete existing tags + roles.Add(AyaType.TagGroup, new BizRoleSet() + { + Change = AuthorizationRoles.BizAdminFull | AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull | AuthorizationRoles.TechFull | AuthorizationRoles.AccountingFull, + EditOwn = AuthorizationRoles.NoRole, + ReadFullRecord = AuthorizationRoles.AnyRole + }); + + //////////////////////////////////////////////////////////// + //TAGGROUPMAP - MIRROR TAGMAP + //Any roles can tag objects and remove tags as per their rights to the taggable object type in question + roles.Add(AyaType.TagGroupMap, new BizRoleSet() + { + Change = AuthorizationRoles.AnyRole, + EditOwn = AuthorizationRoles.NoRole, + ReadFullRecord = AuthorizationRoles.AnyRole + }); + //////////////////////////////////////////////////////////// //OPERATIONS / JOBS diff --git a/server/AyaNova/biz/TagGroupBiz.cs b/server/AyaNova/biz/TagGroupBiz.cs new file mode 100644 index 00000000..6f3e6e0d --- /dev/null +++ b/server/AyaNova/biz/TagGroupBiz.cs @@ -0,0 +1,426 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace AyaNova.Biz +{ + + internal class TagGroupBiz : BizObject, IImportAyaNova7Object + { + private readonly AyContext ct; + public readonly long userId; + private readonly AuthorizationRoles userRoles; + + public bool V7ValidationImportMode { get; set; } + + internal TagGroupBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + V7ValidationImportMode = false;//default + } + + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + internal async Task CreateAsync(string inObj) + { + inObj = CleanTagGroupName(inObj); + Validate(inObj, true); + if (HasErrors) + return null; + else + { + //do stuff with TagGroup + TagGroup outObj = new TagGroup() + { + Name = inObj, + OwnerId = userId + }; + + + await ct.TagGroup.AddAsync(outObj); + return outObj; + } + } + + private static string CleanTagGroupName(string inObj) + { + //Must be lowercase per rules + //This may be naive when we get international customers but for now supporting utf-8 and it appears it's safe to do this with unicode + inObj = inObj.ToLowerInvariant(); + //No spaces in TagGroups, replace with dashes + inObj = inObj.Replace(" ", "-"); + //Remove multiple dash sequences + inObj = System.Text.RegularExpressions.Regex.Replace(inObj, "-+", "-"); + //Ensure doesn't start or end with a dash + inObj = inObj.Trim('-'); + //No longer than 255 characters + inObj = StringUtil.MaxLength(inObj, 255); + return inObj; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get one + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.TagGroup.SingleOrDefaultAsync(m => m.Id == fetchId); + } + + + + //get picklist (paged) + //Unlike most picklists, this one only checks for starts with and wildcards are not supported / treated as part of TagGroup name + internal async Task> GetPickListAsync(IUrlHelper Url, string routeName, PagingOptions pagingOptions, string q) + { + pagingOptions.Offset = pagingOptions.Offset ?? PagingOptions.DefaultOffset; + pagingOptions.Limit = pagingOptions.Limit ?? PagingOptions.DefaultLimit; + + NameIdItem[] items; + int totalRecordCount = 0; + + if (!string.IsNullOrWhiteSpace(q)) + { + //TagGroups are allow saved this way so search this way too + q = q.ToLowerInvariant(); + + items = await ct.TagGroup + //There is some debate on this for efficiency + //I chose this method because I think it escapes potential wildcards in the string automatically + //and I don't want people using wildcards with this, only starts with is supported + //https://stackoverflow.com/questions/45708715/entity-framework-ef-functions-like-vs-string-contains + .Where(m => m.Name.StartsWith(q)) + // .Where(m => EF.Functions.ILike(m.Name, q)) + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.TagGroup.Where(m => m.Name.StartsWith(q)).CountAsync(); + } + else + { + items = await ct.TagGroup + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.TagGroup.CountAsync(); + } + + + + var pageLinks = new PaginationLinkBuilder(Url, routeName, null, pagingOptions, totalRecordCount).PagingLinksObject(); + + ApiPagedResponse pr = new ApiPagedResponse(items, pageLinks); + return pr; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + + //put + internal bool Put(TagGroup dbObj, TagGroup inObj) + { + //Ensure it follows the rules + inObj.Name = CleanTagGroupName(inObj.Name); + + //Replace the db object with the PUT object + CopyObject.Copy(inObj, dbObj, "Id"); + //Set "original" value of concurrency token to input token + //this will allow EF to check it out + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken; + + Validate(dbObj.Name, false); + if (HasErrors) + return false; + + return true; + } + + //patch + internal bool Patch(TagGroup dbObj, JsonPatchDocument objectPatch, uint concurrencyToken) + { + //Validate Patch is allowed + if (!ValidateJsonPatch.Validate(this, objectPatch)) return false; + + //Do the patching + objectPatch.ApplyTo(dbObj); + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = concurrencyToken; + //Ensure it follows the rules + dbObj.Name = CleanTagGroupName(dbObj.Name); + Validate(dbObj.Name, false); + if (HasErrors) + return false; + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(TagGroup dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.TagGroup.Remove(dbObj); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(string inObj, bool isNew) + { + //run validation and biz rules + + //Name required + if (string.IsNullOrWhiteSpace(inObj)) + AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + //Name must be less than 255 characters + if (inObj.Length > 255) + AddError(ValidationErrorType.LengthExceeded, "Name", "255 char max"); + + //Name must be unique + if (ct.TagGroup.Where(m => m.Name == inObj).FirstOrDefault() != null) + AddError(ValidationErrorType.NotUnique, "Name"); + + return; + } + + + //Can delete? + private void ValidateCanDelete(TagGroup inObj) + { + //whatever needs to be check to delete this object + + //See if any TagGroupmaps exist with this TagGroup in which case it's not deleteable + if (ct.TagGroupMap.Any(e => e.TagGroupId == inObj.Id)) + { + AddError(ValidationErrorType.ReferentialIntegrity, "object", "Can't be deleted while has relations"); + } + + } + + + + + ///////////////////////////////////////////////////////////////////// + /// IMPORT v7 implementation + public async Task ImportV7Async(JObject j, List importMap, Guid jobId) + { + //NO TASK TYPE, IT'S ALL THE SAME, KEEPING THIS FOR POSSIBLE FUTURE PURPOSES LIKE APPENDING OBJECT TYPE OR SOMETHING + string SourceType = j["V7_TYPE"].Value(); + switch (SourceType) + { + case "GZTW.AyaNova.BLL.Region": + case "GZTW.AyaNova.BLL.UnitModelCategory": + case "GZTW.AyaNova.BLL.UnitServiceType": + case "GZTW.AyaNova.BLL.WorkorderItemType": + case "GZTW.AyaNova.BLL.ClientGroup": + case "GZTW.AyaNova.BLL.WorkorderCategory": + case "GZTW.AyaNova.BLL.PartCategory": + case "GZTW.AyaNova.BLL.DispatchZone": + case "GZTW.AyaNova.BLL.ScheduleableUserGroup": + { + switch (j["IMPORT_TASK"].Value()) + { + case "main": + { + #region main import task + var NewTagGroupName = j["Name"].Value(); + + var ShortTypeName = string.Empty; + switch (SourceType) + { + case "GZTW.AyaNova.BLL.Region": + ShortTypeName = "rgn"; + break; + case "GZTW.AyaNova.BLL.UnitModelCategory": + ShortTypeName = "unitmdlctgry"; + break; + case "GZTW.AyaNova.BLL.UnitServiceType": + ShortTypeName = "unitsvtyp"; + break; + case "GZTW.AyaNova.BLL.WorkorderItemType": + ShortTypeName = "woitemtyp"; + break; + case "GZTW.AyaNova.BLL.ClientGroup": + ShortTypeName = "clntgrp"; + break; + case "GZTW.AyaNova.BLL.WorkorderCategory": + ShortTypeName = "woctgry"; + break; + case "GZTW.AyaNova.BLL.PartCategory": + ShortTypeName = "prtctgry"; + break; + case "GZTW.AyaNova.BLL.DispatchZone": + ShortTypeName = "dspchzn"; + break; + case "GZTW.AyaNova.BLL.ScheduleableUserGroup": + ShortTypeName = "schdusrgrp"; + break; + } + + + NewTagGroupName += "." + ShortTypeName; + var OldV7Id = new Guid(j["ID"].Value()); + + //Ensure it follows the rules + NewTagGroupName = CleanTagGroupName(NewTagGroupName); + + //There might already be a TagGroup of the same name since so many different types of V7 objects are becoming TagGroups + //Weighed the pros and cons of uniquifying by object type versus just using the same name for different object types: + //it seems to me at this point that people might desire the same exact name because if they used it that way they probably + //intended it that way, so decision is to check if it already exists and then use that ID in the importMap instead + //for matching other objects imported to TagGroups + + //Already present? + var ExistingTagGroup = ct.TagGroup.Where(m => m.Name == NewTagGroupName).FirstOrDefault(); + if (ExistingTagGroup != null) + { + //map it to the existing TagGroup of same name + var mapItem = new ImportAyaNova7MapItem(OldV7Id, AyaType.TagGroup, ExistingTagGroup.Id); + } + else + { + + TagGroup o = await CreateAsync(NewTagGroupName); + if (HasErrors) + { + //If there are any validation errors, log in joblog and move on + JobsBiz.LogJob(jobId, $"TagGroupBiz::ImportV7Async -> import object \"{NewTagGroupName}\" of type \"{SourceType}\" source id {OldV7Id.ToString()} failed validation and was not imported: {GetErrorsAsString()} ", ct); + return false; + } + else + { + await ct.SaveChangesAsync(); + var mapItem = new ImportAyaNova7MapItem(OldV7Id, AyaType.TagGroup, o.Id); + importMap.Add(mapItem); + await ImportAyaNova7Biz.LogEventCreatedModifiedEvents(j, importMap, AyaType.TagGroup, ct); + } + } + #endregion + } + break; + case "scheduleableusergroupTagGroups": + { + #region attribute sched user group TagGroups + /* + { + "ID": "871e77b2-979a-4f26-930b-46f7c05fc19f", + "Created": "08/30/2018 08:12 AM", + "Modified": "08/30/2018 08:13 AM", + "Creator": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "Modifier": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "Name": "Yet another test group", + "Active": true, + "Description": "More testing yay!", + "ScheduleableUsers": [ + { + "Created": "08/30/2018 08:13 AM", + "Creator": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "Modified": "08/30/2018 08:13 AM", + "Modifier": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "ID": "676475be-8301-47d0-bd54-af9dbd1fe7eb", + "ScheduleableUserID": "1d859264-3f32-462a-9b0c-a67dddfdf4d3", + "ScheduleableUserGroupID": "871e77b2-979a-4f26-930b-46f7c05fc19f" + }, + { + "Created": "08/30/2018 08:13 AM", + "Creator": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "Modified": "08/30/2018 08:13 AM", + "Modifier": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "ID": "173499c3-a616-42a0-b08c-74008f8fa352", + "ScheduleableUserID": "42b282bb-100b-4b31-aa14-5c831d7cda66", + "ScheduleableUserGroupID": "871e77b2-979a-4f26-930b-46f7c05fc19f" + }, + { + "Created": "08/30/2018 08:13 AM", + "Creator": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "Modified": "08/30/2018 08:13 AM", + "Modifier": "2ecc77fc-69e2-4a7e-b88d-bd0ecaf36aed", + "ID": "19c9b3d6-eeb2-44ac-be4e-6ec93d15b02a", + "ScheduleableUserID": "e6ff9bc6-a550-4242-8c41-857f740e2841", + "ScheduleableUserGroupID": "871e77b2-979a-4f26-930b-46f7c05fc19f" + } + ] + } + */ + var V7Id = new Guid(j["ID"].Value()); + var RavenTagGroupId = importMap.Where(m => m.V7ObjectId == V7Id).First().NewObjectAyaTypeId.ObjectId; + + foreach (JToken t in j["ScheduleableUsers"]) + { + var techId = new Guid(t["ScheduleableUserID"].Value()); + var RavenUserId = importMap.Where(m => m.V7ObjectId == techId).First().NewObjectAyaTypeId.ObjectId; + var Creator = importMap.Where(m => m.V7ObjectId == new Guid(t["Creator"].Value())).First().NewObjectAyaTypeId.ObjectId; + + TagGroupMap tm = new TagGroupMap(); + tm.TagGroupToObjectId = RavenUserId; + tm.TagGroupToObjectType=AyaType.User; + tm.TagGroupId = RavenTagGroupId; + tm.OwnerId = Creator; + ct.TagGroupMap.Add(tm); + } + ct.SaveChanges(); + + #endregion + } + break; + } + + } + break; + + + } + + //this is the equivalent of returning void for a Task signature with nothing to return + return true; + } + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + // +}//eons + diff --git a/server/AyaNova/biz/TagGroupMapBiz.cs b/server/AyaNova/biz/TagGroupMapBiz.cs new file mode 100644 index 00000000..a2c8ad1c --- /dev/null +++ b/server/AyaNova/biz/TagGroupMapBiz.cs @@ -0,0 +1,177 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; +using System.Collections.Generic; + + +namespace AyaNova.Biz +{ + + + internal class TagGroupMapBiz : BizObject + { + private readonly AyContext ct; + public readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal TagGroupMapBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + internal async Task CreateAsync(TagGroupMapInfo inObj) + { + + Validate(inObj, true); + if (HasErrors) + return null; + else + { + //do stuff with TagGroupMap + TagGroupMap outObj = new TagGroupMap() + { + TagId = inObj.TagId, + TagToObjectId = inObj.TagToObjectId, + TagToObjectType = inObj.TagToObjectType, + OwnerId = userId + }; + + + await ct.TagGroupMap.AddAsync(outObj); + return outObj; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get one + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.TagGroupMap.SingleOrDefaultAsync(m => m.Id == fetchId); + } + + + internal async Task> GetTagsOnObjectListAsync(AyaTypeId tid) + { + /* + + NOTES: This will be a bit of a "hot" path as every fetch of any main object will involve this + for now, just make it work and later can improve performance + + Also is sort going to be adequate, it's supposed to be based on invariant culture + + */ + + List l = new List(); + + //Get the list of tags on the object + var TagGroupMapsOnObject = await ct.TagGroupMap + .Where(m => m.TagToObjectId == tid.ObjectId && m.TagToObjectType == tid.ObjectType) + .Select(m => m.TagId) + .ToListAsync(); + + foreach (long tagId in TagGroupMapsOnObject) + { + var tagFromDb = await ct.Tag.SingleOrDefaultAsync(m => m.Id == tagId); + if (tagFromDb != null) + { + l.Add(new NameIdItem() { Id = tagFromDb.Id, Name = tagFromDb.Name }); + } + } + + //Return the list sorted alphabetically + //Note if this is commonly required then maybe make a helper / extension for it + return (l.OrderBy(o => o.Name).ToList()); + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(TagGroupMap dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.TagGroupMap.Remove(dbObj); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE ALL TagGroupMapS FOR OBJECT + // + + static internal bool DeleteAllForObject(AyaTypeId parentObj, AyContext ct) + { + //Be careful in future, if you put ToString at the end of each object in the string interpolation + //npgsql driver will assume it's a string and put quotes around it triggering an error that a string can't be compared to an int + ct.Database.ExecuteSqlCommand($"delete from aTagGroupMap where tagtoobjectid={parentObj.ObjectId} and tagtoobjecttype={parentObj.ObjectTypeAsInt}"); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(TagGroupMapInfo inObj, bool isNew) + { + //run validation and biz rules + + // //Name required + // if (string.IsNullOrWhiteSpace(inObj)) + // AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + // //Name must be less than 255 characters + // if (inObj.Length > 255) + // AddError(ValidationErrorType.LengthExceeded, "Name", "255 char max"); + + return; + } + + + //Can delete? + private void ValidateCanDelete(TagGroupMap inObj) + { + //whatever needs to be check to delete this object + + //See if any TagGroupMaps exist with this tag in which case it's not deleteable + // if (ct.TagGroupMap.Any(e => e.TagGroupMapId == inObj.Id)) + // { + // AddError(ValidationErrorType.ReferentialIntegrity, "object", "Can't be deleted while has relations"); + // } + + } + + + ///////////////////////////////////////////////////////////////////// + + + + }//eoc + + +}//eons + diff --git a/server/AyaNova/models/AyContext.cs b/server/AyaNova/models/AyContext.cs index 554020ab..450e49f8 100644 --- a/server/AyaNova/models/AyContext.cs +++ b/server/AyaNova/models/AyContext.cs @@ -16,6 +16,8 @@ namespace AyaNova.Models public virtual DbSet FileAttachment { get; set; } public virtual DbSet Tag { get; set; } public virtual DbSet TagMap { get; set; } + public virtual DbSet TagGroup { get; set; } + public virtual DbSet TagGroupMap { get; set; } public virtual DbSet OpsJob { get; set; } public virtual DbSet OpsJobLog { get; set; } public virtual DbSet Locale { get; set; } diff --git a/server/AyaNova/models/TagGroup.cs b/server/AyaNova/models/TagGroup.cs new file mode 100644 index 00000000..f5d8f5e1 --- /dev/null +++ b/server/AyaNova/models/TagGroup.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + public partial class TagGroup + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + + [Required] + public long OwnerId { get; set; } + [Required, MaxLength(255)] + public string Name { get; set; }//max 255 characters ascii set + + } +} diff --git a/server/AyaNova/models/TagGroupMap.cs b/server/AyaNova/models/TagGroupMap.cs new file mode 100644 index 00000000..fd639933 --- /dev/null +++ b/server/AyaNova/models/TagGroupMap.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + public partial class TagGroupMap + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + [Required] + public long OwnerId { get; set; } + + [Required] + public long TagId { get; set; } + [Required] + public long TagGroupId { get; set; } + + } +} diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs index 2f39e70b..e1588b8b 100644 --- a/server/AyaNova/util/AySchema.cs +++ b/server/AyaNova/util/AySchema.cs @@ -144,7 +144,7 @@ namespace AyaNova.Util "dlkey text, dlkeyexpire timestamp, usertype integer not null, employeenumber varchar(255), notes text, clientid bigint, headofficeid bigint, subvendorid bigint)"); //Add user options table - exec("CREATE TABLE auseroptions (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, "+ + exec("CREATE TABLE auseroptions (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, " + "userid bigint not null, timezoneoffset decimal(19,5) not null default 0, emailaddress text, uicolor int not null default 0)"); @@ -211,6 +211,10 @@ namespace AyaNova.Util exec("CREATE TABLE atagmap (id BIGSERIAL PRIMARY KEY, ownerid bigint not null," + "tagid bigint not null REFERENCES atag (id), tagtoobjectid bigint not null, tagtoobjecttype integer not null)"); + //Taggroup + exec("CREATE TABLE ataggroup (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, name varchar(255) not null)"); + exec("CREATE TABLE ataggroupmap (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, tagid bigint not null REFERENCES atag (id), taggroupid bigint not null)"); + setSchemaLevel(++currentSchema); }