snapshot of https://github.com/ent/ent.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Atlas CI Public / lint (push) Failing after 13s
Atlas CI Public / sync (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:12:36 +05:00
commit 4b3ad0b611
2680 changed files with 719472 additions and 0 deletions

855
.all-contributorsrc Normal file
View File

@@ -0,0 +1,855 @@
{
"files": [
"doc/md/contributors.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "a8m",
"name": "Ariel Mashraki",
"avatar_url": "https://avatars.githubusercontent.com/u/7413593?v=4",
"profile": "https://github.com/a8m",
"contributions": [
"maintenance",
"doc",
"code"
]
},
{
"login": "alexsn",
"name": "Alex Snast",
"avatar_url": "https://avatars.githubusercontent.com/u/987019?v=4",
"profile": "https://github.com/alexsn",
"contributions": [
"code"
]
},
{
"login": "rotemtam",
"name": "Rotem Tamir",
"avatar_url": "https://avatars.githubusercontent.com/u/1522681?v=4",
"profile": "https://rotemtam.com/",
"contributions": [
"maintenance",
"doc",
"code"
]
},
{
"login": "cliedeman",
"name": "Ciaran Liedeman",
"avatar_url": "https://avatars.githubusercontent.com/u/3578740?v=4",
"profile": "https://github.com/cliedeman",
"contributions": [
"code"
]
},
{
"login": "marwan-at-work",
"name": "Marwan Sulaiman",
"avatar_url": "https://avatars.githubusercontent.com/u/16294261?v=4",
"profile": "https://www.marwan.io/",
"contributions": [
"code"
]
},
{
"login": "napei",
"name": "Nathaniel Peiffer",
"avatar_url": "https://avatars.githubusercontent.com/u/8946502?v=4",
"profile": "https://nathaniel.peiffer.com.au/",
"contributions": [
"code"
]
},
{
"login": "tmc",
"name": "Travis Cline",
"avatar_url": "https://avatars.githubusercontent.com/u/3977?v=4",
"profile": "https://github.com/tmc",
"contributions": [
"code"
]
},
{
"login": "hantmac",
"name": "Jeremy",
"avatar_url": "https://avatars.githubusercontent.com/u/7600925?v=4",
"profile": "https://cloudsjhan.github.io/",
"contributions": [
"code"
]
},
{
"login": "aca",
"name": "aca",
"avatar_url": "https://avatars.githubusercontent.com/u/50316549?v=4",
"profile": "https://github.com/aca",
"contributions": [
"code"
]
},
{
"login": "BrentChesny",
"name": "BrentChesny",
"avatar_url": "https://avatars.githubusercontent.com/u/1449435?v=4",
"profile": "https://github.com/BrentChesny",
"contributions": [
"code",
"doc"
]
},
{
"login": "giautm",
"name": "Giau. Tran Minh",
"avatar_url": "https://avatars.githubusercontent.com/u/12751435?v=4",
"profile": "https://github.com/giautm",
"contributions": [
"code",
"review"
]
},
{
"login": "htdvisser",
"name": "Hylke Visser",
"avatar_url": "https://avatars.githubusercontent.com/u/181308?v=4",
"profile": "https://htdvisser.dev/",
"contributions": [
"code"
]
},
{
"login": "kerbelp",
"name": "Pavel Kerbel",
"avatar_url": "https://avatars.githubusercontent.com/u/3934990?v=4",
"profile": "https://github.com/kerbelp",
"contributions": [
"code"
]
},
{
"login": "day-dreams",
"name": "zhangnan",
"avatar_url": "https://avatars.githubusercontent.com/u/24593904?v=4",
"profile": "https://github.com/day-dreams",
"contributions": [
"code"
]
},
{
"login": "uta-mori",
"name": "mori yuta",
"avatar_url": "https://avatars.githubusercontent.com/u/59682979?v=4",
"profile": "https://github.com/uta-mori",
"contributions": [
"code",
"translation",
"review"
]
},
{
"login": "chris-rock",
"name": "Christoph Hartmann",
"avatar_url": "https://avatars.githubusercontent.com/u/1178413?v=4",
"profile": "http://lollyrock.com/",
"contributions": [
"code"
]
},
{
"login": "rubensayshi",
"name": "Ruben de Vries",
"avatar_url": "https://avatars.githubusercontent.com/u/649160?v=4",
"profile": "https://github.com/rubensayshi",
"contributions": [
"code"
]
},
{
"login": "ernado",
"name": "Aleksandr Razumov",
"avatar_url": "https://avatars.githubusercontent.com/u/866677?v=4",
"profile": "https://keybase.io/ernado",
"contributions": [
"code"
]
},
{
"login": "apbuteau",
"name": "apbuteau",
"avatar_url": "https://avatars.githubusercontent.com/u/6796073?v=4",
"profile": "https://github.com/apbuteau",
"contributions": [
"code"
]
},
{
"login": "ichord",
"name": "Harold.Luo",
"avatar_url": "https://avatars.githubusercontent.com/u/1324791?v=4",
"profile": "https://github.com/ichord",
"contributions": [
"code"
]
},
{
"login": "idoshveki",
"name": "ido shveki",
"avatar_url": "https://avatars.githubusercontent.com/u/11615669?v=4",
"profile": "https://github.com/idoshveki",
"contributions": [
"code"
]
},
{
"login": "masseelch",
"name": "MasseElch",
"avatar_url": "https://avatars.githubusercontent.com/u/12862103?v=4",
"profile": "https://github.com/masseelch",
"contributions": [
"code"
]
},
{
"login": "kidlj",
"name": "Jian Li",
"avatar_url": "https://avatars.githubusercontent.com/u/300616?v=4",
"profile": "https://github.com/kidlj",
"contributions": [
"code"
]
},
{
"login": "nolotz",
"name": "Noah-Jerome Lotzer",
"avatar_url": "https://avatars.githubusercontent.com/u/5778728?v=4",
"profile": "https://noah.je/",
"contributions": [
"code"
]
},
{
"login": "danf0rth",
"name": "danforth",
"avatar_url": "https://avatars.githubusercontent.com/u/14220891?v=4",
"profile": "https://github.com/danf0rth",
"contributions": [
"code"
]
},
{
"login": "maxiloEmmmm",
"name": "maxilozoz",
"avatar_url": "https://avatars.githubusercontent.com/u/16779121?v=4",
"profile": "https://github.com/maxiloEmmmm",
"contributions": [
"code"
]
},
{
"login": "zzwx",
"name": "zzwx",
"avatar_url": "https://avatars.githubusercontent.com/u/8169082?v=4",
"profile": "https://gist.github.com/zzwx",
"contributions": [
"code"
]
},
{
"login": "ix64",
"name": "MengYX",
"avatar_url": "https://avatars.githubusercontent.com/u/13902388?v=4",
"profile": "https://github.com/ix64",
"contributions": [
"translation"
]
},
{
"login": "mattn",
"name": "mattn",
"avatar_url": "https://avatars.githubusercontent.com/u/10111?v=4",
"profile": "https://mattn.kaoriya.net/",
"contributions": [
"translation"
]
},
{
"login": "Bladrak",
"name": "Hugo Briand",
"avatar_url": "https://avatars.githubusercontent.com/u/1321977?v=4",
"profile": "https://github.com/Bladrak",
"contributions": [
"code"
]
},
{
"login": "enmand",
"name": "Dan Enman",
"avatar_url": "https://avatars.githubusercontent.com/u/432487?v=4",
"profile": "https://danielenman.com/",
"contributions": [
"code"
]
},
{
"login": "UnAfraid",
"name": "Rumen Nikiforov",
"avatar_url": "https://avatars.githubusercontent.com/u/2185291?v=4",
"profile": "http://www.l2junity.org/",
"contributions": [
"code"
]
},
{
"login": "wenerme",
"name": "陈杨文",
"avatar_url": "https://avatars.githubusercontent.com/u/1777211?v=4",
"profile": "https://wener.me",
"contributions": [
"code"
]
},
{
"login": "joesonw",
"name": "Qiaosen (Joeson) Huang",
"avatar_url": "https://avatars.githubusercontent.com/u/1635441?v=4",
"profile": "https://djwong.net",
"contributions": [
"bug"
]
},
{
"login": "davebehr1",
"name": "AlonDavidBehr",
"avatar_url": "https://avatars.githubusercontent.com/u/16716239?v=4",
"profile": "https://github.com/davebehr1",
"contributions": [
"code",
"review"
]
},
{
"login": "DuGlaser",
"name": "DuGlaser",
"avatar_url": "https://avatars.githubusercontent.com/u/50506482?v=4",
"profile": "http://duglaser.dev",
"contributions": [
"doc"
]
},
{
"login": "shanna",
"name": "Shane Hanna",
"avatar_url": "https://avatars.githubusercontent.com/u/28489?v=4",
"profile": "https://github.com/shanna",
"contributions": [
"doc"
]
},
{
"login": "mahmud2011",
"name": "Mahmudul Haque",
"avatar_url": "https://avatars.githubusercontent.com/u/5278142?v=4",
"profile": "https://www.linkedin.com/in/mahmud2011",
"contributions": [
"code"
]
},
{
"login": "sywesk",
"name": "Benjamin Bourgeais",
"avatar_url": "https://avatars.githubusercontent.com/u/862607?v=4",
"profile": "http://blog.scaleprocess.net",
"contributions": [
"code"
]
},
{
"login": "8ayac",
"name": "8ayac(Yoshinori Hayashi)",
"avatar_url": "https://avatars.githubusercontent.com/u/29266382?v=4",
"profile": "https://about.8ay.ac/",
"contributions": [
"doc"
]
},
{
"login": "y-yagi",
"name": "y-yagi",
"avatar_url": "https://avatars.githubusercontent.com/u/987638?v=4",
"profile": "https://github.com/y-yagi",
"contributions": [
"doc"
]
},
{
"login": "Sacro",
"name": "Ben Woodward",
"avatar_url": "https://avatars.githubusercontent.com/u/2659869?v=4",
"profile": "https://github.com/Sacro",
"contributions": [
"code"
]
},
{
"login": "wzyjerry",
"name": "WzyJerry",
"avatar_url": "https://avatars.githubusercontent.com/u/11435169?v=4",
"profile": "https://github.com/wzyjerry",
"contributions": [
"code"
]
},
{
"login": "tarrencev",
"name": "Tarrence van As",
"avatar_url": "https://avatars.githubusercontent.com/u/4740651?v=4",
"profile": "https://github.com/tarrencev",
"contributions": [
"doc",
"code"
]
},
{
"login": "MONAKA0721",
"name": "Yuya Sumie",
"avatar_url": "https://avatars.githubusercontent.com/u/32859963?v=4",
"profile": "https://mo7ka.com",
"contributions": [
"doc"
]
},
{
"login": "akfaew",
"name": "Michal Mazurek",
"avatar_url": "https://avatars.githubusercontent.com/u/7853732?v=4",
"profile": "http://jasminek.net",
"contributions": [
"code"
]
},
{
"login": "nmemoto",
"name": "Takafumi Umemoto",
"avatar_url": "https://avatars.githubusercontent.com/u/1522332?v=4",
"profile": "https://github.com/nmemoto",
"contributions": [
"doc"
]
},
{
"login": "squarebat",
"name": "Khadija Sidhpuri",
"avatar_url": "https://avatars.githubusercontent.com/u/59063821?v=4",
"profile": "http://www.linkedin.com/in/khadija-sidhpuri-87709316a",
"contributions": [
"code"
]
},
{
"login": "neel229",
"name": "Neel Modi",
"avatar_url": "https://avatars.githubusercontent.com/u/53475167?v=4",
"profile": "https://github.com/neel229",
"contributions": [
"code"
]
},
{
"login": "shomodj",
"name": "Boris Shomodjvarac",
"avatar_url": "https://avatars.githubusercontent.com/u/304768?v=4",
"profile": "https://ie.linkedin.com/in/boris-shomodjvarac-51970879",
"contributions": [
"doc"
]
},
{
"login": "sadmansakib",
"name": "Sadman Sakib",
"avatar_url": "https://avatars.githubusercontent.com/u/17023844?v=4",
"profile": "https://github.com/sadmansakib",
"contributions": [
"doc"
]
},
{
"login": "dakimura",
"name": "dakimura",
"avatar_url": "https://avatars.githubusercontent.com/u/34202807?v=4",
"profile": "https://github.com/dakimura",
"contributions": [
"code"
]
},
{
"login": "RiskyFeryansyahP",
"name": "Risky Feryansyah",
"avatar_url": "https://avatars.githubusercontent.com/u/36788585?v=4",
"profile": "https://github.com/RiskyFeryansyahP",
"contributions": [
"code"
]
},
{
"login": "seiichi1101",
"name": "seiichi ",
"avatar_url": "https://avatars.githubusercontent.com/u/20941952?v=4",
"profile": "https://github.com/seiichi1101",
"contributions": [
"code"
]
},
{
"login": "odeke-em",
"name": "Emmanuel T Odeke",
"avatar_url": "https://avatars.githubusercontent.com/u/4898263?v=4",
"profile": "https://orijtech.com/",
"contributions": [
"code"
]
},
{
"login": "isoppp",
"name": "Hiroki Isogai",
"avatar_url": "https://avatars.githubusercontent.com/u/16318727?v=4",
"profile": "https://isoppp.com",
"contributions": [
"doc"
]
},
{
"login": "tsingsun",
"name": "李清山",
"avatar_url": "https://avatars.githubusercontent.com/u/5848549?v=4",
"profile": "https://github.com/tsingsun",
"contributions": [
"code"
]
},
{
"login": "s-takehana",
"name": "s-takehana",
"avatar_url": "https://avatars.githubusercontent.com/u/3423547?v=4",
"profile": "https://github.com/s-takehana",
"contributions": [
"doc"
]
},
{
"login": "EndlessIdea",
"name": "Kuiba",
"avatar_url": "https://avatars.githubusercontent.com/u/1527796?v=4",
"profile": "https://github.com/EndlessIdea",
"contributions": [
"code"
]
},
{
"login": "storyicon",
"name": "storyicon",
"avatar_url": "https://avatars.githubusercontent.com/u/29772821?v=4",
"profile": "https://github.com/storyicon",
"contributions": [
"code"
]
},
{
"login": "evanlurvey",
"name": "Evan Lurvey",
"avatar_url": "https://avatars.githubusercontent.com/u/54965655?v=4",
"profile": "https://github.com/evanlurvey",
"contributions": [
"code"
]
},
{
"login": "attackordie",
"name": "Brian",
"avatar_url": "https://avatars.githubusercontent.com/u/20145334?v=4",
"profile": "https://github.com/attackordie",
"contributions": [
"doc"
]
},
{
"login": "ThinkontrolSY",
"name": "Shen Yang",
"avatar_url": "https://avatars.githubusercontent.com/u/11331554?v=4",
"profile": "http://www.thinkontrol.com",
"contributions": [
"code"
]
},
{
"login": "sivchari",
"name": "sivchari",
"avatar_url": "https://avatars.githubusercontent.com/u/55221074?v=4",
"profile": "https://twitter.com/sivchari",
"contributions": [
"code"
]
},
{
"login": "mookjp",
"name": "mook",
"avatar_url": "https://avatars.githubusercontent.com/u/1519309?v=4",
"profile": "https://blog.mookjp.io",
"contributions": [
"code"
]
},
{
"login": "heliumbrain",
"name": "heliumbrain",
"avatar_url": "https://avatars.githubusercontent.com/u/1607668?v=4",
"profile": "http://www.entiros.se",
"contributions": [
"doc"
]
},
{
"login": "JeremyV2014",
"name": "Jeremy Maxey-Vesperman",
"avatar_url": "https://avatars.githubusercontent.com/u/9276415?v=4",
"profile": "https://github.com/JeremyV2014",
"contributions": [
"code",
"doc"
]
},
{
"login": "tankbusta",
"name": "Christopher Schmitt",
"avatar_url": "https://avatars.githubusercontent.com/u/592749?v=4",
"profile": "https://github.com/tankbusta",
"contributions": [
"doc"
]
},
{
"login": "grevych",
"name": "Gerardo Reyes",
"avatar_url": "https://avatars.githubusercontent.com/u/3792003?v=4",
"profile": "https://github.com/grevych",
"contributions": [
"code"
]
},
{
"login": "naormatania",
"name": "Naor Matania",
"avatar_url": "https://avatars.githubusercontent.com/u/6978437?v=4",
"profile": "https://github.com/naormatania",
"contributions": [
"code"
]
},
{
"login": "idc77",
"name": "idc77",
"avatar_url": "https://avatars.githubusercontent.com/u/87644834?v=4",
"profile": "https://github.com/idc77",
"contributions": [
"doc"
]
},
{
"login": "HurSungYun",
"name": "Sungyun Hur",
"avatar_url": "https://avatars.githubusercontent.com/u/8033896?v=4",
"profile": "http://ethanhur.me",
"contributions": [
"doc"
]
},
{
"login": "peanut-cc",
"name": "peanut-pg",
"avatar_url": "https://avatars.githubusercontent.com/u/55480838?v=4",
"profile": "https://github.com/peanut-cc",
"contributions": [
"doc"
]
},
{
"login": "m3hm3t",
"name": "Mehmet Yılmaz",
"avatar_url": "https://avatars.githubusercontent.com/u/22320354?v=4",
"profile": "https://github.com/m3hm3t",
"contributions": [
"code"
]
},
{
"login": "Laconty",
"name": "Roman Maklakov",
"avatar_url": "https://avatars.githubusercontent.com/u/17760166?v=4",
"profile": "https://github.com/Laconty",
"contributions": [
"code"
]
},
{
"login": "genevieve",
"name": "Genevieve",
"avatar_url": "https://avatars.githubusercontent.com/u/12158641?v=4",
"profile": "https://github.com/genevieve",
"contributions": [
"code"
]
},
{
"login": "cjraa",
"name": "Clarence",
"avatar_url": "https://avatars.githubusercontent.com/u/62199269?v=4",
"profile": "https://github.com/cjraa",
"contributions": [
"code"
]
},
{
"login": "iamnande",
"name": "Nicholas Anderson",
"avatar_url": "https://avatars.githubusercontent.com/u/7806510?v=4",
"profile": "https://www.linkedin.com/in/iamnande/",
"contributions": [
"code"
]
},
{
"login": "hezhizhen",
"name": "Zhizhen He",
"avatar_url": "https://avatars.githubusercontent.com/u/7611700?v=4",
"profile": "https://github.com/hezhizhen",
"contributions": [
"code"
]
},
{
"login": "crossworth",
"name": "Pedro Henrique",
"avatar_url": "https://avatars.githubusercontent.com/u/1251151?v=4",
"profile": "https://pedro.dev.br",
"contributions": [
"code"
]
},
{
"login": "MrParano1d",
"name": "MrParano1d",
"avatar_url": "https://avatars.githubusercontent.com/u/7414374?v=4",
"profile": "https://2jp.de",
"contributions": [
"code"
]
},
{
"login": "tprebs",
"name": "Thomas Prebble",
"avatar_url": "https://avatars.githubusercontent.com/u/6523587?v=4",
"profile": "https://github.com/tprebs",
"contributions": [
"code"
]
},
{
"login": "imhuytq",
"name": "Huy TQ",
"avatar_url": "https://avatars.githubusercontent.com/u/5723282?v=4",
"profile": "https://huytq.com",
"contributions": [
"code"
]
},
{
"login": "maorlipchuk",
"name": "maorlipchuk",
"avatar_url": "https://avatars.githubusercontent.com/u/7034637?v=4",
"profile": "https://github.com/maorlipchuk",
"contributions": [
"code"
]
},
{
"login": "iwata",
"name": "Motonori Iwata",
"avatar_url": "https://avatars.githubusercontent.com/u/121048?v=4",
"profile": "https://mobcov.hatenadiary.org/",
"contributions": [
"doc"
]
},
{
"login": "CharlesGe129",
"name": "Charles Ge",
"avatar_url": "https://avatars.githubusercontent.com/u/20162173?v=4",
"profile": "https://github.com/CharlesGe129",
"contributions": [
"code"
]
},
{
"login": "thmeitz",
"name": "Thomas Meitz",
"avatar_url": "https://avatars.githubusercontent.com/u/92851940?v=4",
"profile": "https://github.com/thmeitz",
"contributions": [
"code",
"doc"
]
},
{
"login": "booleangate",
"name": "Justin Johnson",
"avatar_url": "https://avatars.githubusercontent.com/u/181567?v=4",
"profile": "http://justinjohnson.org",
"contributions": [
"code"
]
},
{
"login": "hax10",
"name": "hax10",
"avatar_url": "https://avatars.githubusercontent.com/u/85743468?v=4",
"profile": "https://github.com/hax10",
"contributions": [
"code"
]
},
{
"login": "water-a",
"name": "water-a",
"avatar_url": "https://avatars.githubusercontent.com/u/38114545?v=4",
"profile": "https://github.com/water-a",
"contributions": [
"bug"
]
},
{
"login": "jhwz",
"name": "jhwz",
"avatar_url": "https://avatars.githubusercontent.com/u/52683873?v=4",
"profile": "https://github.com/jhwz",
"contributions": [
"doc"
]
},
{
"login": "kortschak",
"name": "Dan Kortschak",
"avatar_url": "https://avatars.githubusercontent.com/u/275221?v=4",
"profile": "https://kortschak.io/",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,
"projectName": "ent",
"projectOwner": "ent",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
}

View File

@@ -0,0 +1,19 @@
{
"features": {
"ghcr.io/devcontainers/features/copilot-cli:1": {
"version": "1.1.2",
"resolved": "ghcr.io/devcontainers/features/copilot-cli@sha256:757c6c2899dc902c44a9f6164c1f7392832ced13c6bb632d8d60a880f2e92456",
"integrity": "sha256:757c6c2899dc902c44a9f6164c1f7392832ced13c6bb632d8d60a880f2e92456"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "2.17.0",
"resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c",
"integrity": "sha256:25b9f05705ffba7dbe503230ac76081419306f8c8bc88e0ce78c4ecd99a0c78c"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
}
}
}

View File

@@ -0,0 +1,37 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/go .
{
"name": "Go",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/go:2-1.26",
"features": {
"ghcr.io/devcontainers/features/copilot-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"dockerDashComposeVersion": "latest",
"installDockerBuildx": true,
"moby": false,
"version": "latest"
},
"ghcr.io/devcontainers/features/github-cli:1": {
"installDirectlyFromGitHubRelease": true,
"version": "latest"
}
}
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "go version",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

58
.github/ISSUE_TEMPLATE/1.bug.md vendored Normal file
View File

@@ -0,0 +1,58 @@
---
name: Bug report 🐛
about: Create a bug report.
labels: 'status: needs triage'
---
<!-- Provide a general summary of the issue in the Title above -->
<!--
Thank you very much for contributing to Ent by creating an issue! ❤️
To avoid duplicate issues we ask you to check off the following list.
-->
<!-- Checked checkbox should look like this: [x] -->
- [ ] The issue is present in the latest release.
- [ ] I have searched the [issues](https://github.com/ent/ent/issues) of this repository and believe that this is not a duplicate.
## Current Behavior 😯
<!-- Describe what happens instead of the expected behavior. -->
## Expected Behavior 🤔
<!-- Describe what should happen. -->
## Steps to Reproduce 🕹
<!--
Are you able to reproduce this bug? If so, follow the steps in the ent/bug repository: https://github.com/ent/bug
After you are done, paste the link to your new repository here (make sure it's public)!
(In this case, you can disregard filling out the steps below)
-->
<!--
TODO: reproduction repo
-->
Steps:
1.
2.
3.
4.
## Your Environment 🌎
<!--
Include as many relevant details about the environment with which you experienced the bug.
-->
| Tech | Version |
| ----------- | ------- |
| Go | 1.17.? |
| Ent | 0.9.? |
| Database | MySQL |
| Driver | https://github.com/go-sql-driver/mysql |

27
.github/ISSUE_TEMPLATE/2.feature.md vendored Normal file
View File

@@ -0,0 +1,27 @@
---
name: Feature request 🎉
about: Suggest a new idea for the project.
labels: 'status: needs triage'
---
<!-- Provide a general summary of the feature in the Title above -->
<!--
Thank you very much for contributing to Ent by creating an issue! ❤️
To avoid duplicate issues we ask you to check off the following list.
-->
<!-- Checked checkbox should look like this: [x] -->
- [ ] I have searched the [issues](https://github.com/ent/ent/issues) of this repository and believe that this is not a duplicate.
## Summary 💡
<!-- Describe how it should work. -->
## Motivation 🔦
<!--
What are you trying to accomplish? How has the lack of this feature affected you?
Providing context helps us come up with a solution that is most useful in the real world.
-->

5
.github/ISSUE_TEMPLATE/3.support.md vendored Normal file
View File

@@ -0,0 +1,5 @@
---
name: Question
about: General support
labels: 'status: needs triage'
---

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false # force the usage of a template
contact_links:
- name: Something Else ❔
url: https://gophers.slack.com/archives/C01FMSQDT53
about: Come chat to us in the gophers slack

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: gomod
directory: /
schedule:
interval: daily

57
.github/workflows/atlas-ci-public.yaml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Atlas CI Public
on:
# Run whenever code is changed in the master branch,
# change this to your root branch.
push:
branches:
- master
paths:
- 'examples/migration/ent/migrate/migrations/*'
pull_request:
paths:
- 'examples/migration/ent/migrate/migrations/*'
jobs:
sync:
permissions:
contents: read
id-token: write
needs: lint
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ariga/atlas-sync-action@v0
with:
dir: 'examples/migration/ent/migrate/migrations'
driver: mysql # or: postgres | sqlite
cloud-public: true
lint:
permissions:
contents: read
id-token: write
pull-requests: write
services:
# Spin up a mysql:8.0.29 container to be used as the dev-database for analysis.
mysql:
image: mysql:8.0.29
env:
MYSQL_ROOT_PASSWORD: pass
MYSQL_DATABASE: dev
ports:
- "3306:3306"
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Mandatory unless "latest" is set below.
- uses: ariga/atlas-action@v0
with:
dir: 'examples/migration/ent/migrate/migrations'
dev-url: mysql://root:pass@localhost:3306/dev
cloud-public: true

501
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,501 @@
name: Continuous Integration
on:
push:
paths-ignore:
- 'doc/**'
tags-ignore:
- '*.*'
pull_request:
paths-ignore:
- 'doc/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: './go.mod'
- name: Run linters
uses: golangci/golangci-lint-action@v5
with:
args: --verbose
unit:
runs-on: ubuntu-latest
strategy:
matrix:
go: ['1.23', '1.24']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run cmd tests
run: go test -race ./...
working-directory: cmd
- name: Run dialect tests
run: go test -race ./...
working-directory: dialect
- name: Run schema tests
run: go test -race ./...
working-directory: schema
- name: Run loader tests
run: go test -race ./...
working-directory: entc/load
- name: Run codegen tests
run: go test -race ./...
working-directory: entc/gen
- name: Run example tests
working-directory: examples
run: go test -race ./...
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: './go.mod'
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run go generate
run: go generate ./... && go mod tidy
- name: Run go generate on examples directory
working-directory: examples
run: go generate ./... && go mod tidy
- name: Run go generate on integration directory
working-directory: entc/integration
run: go generate ./... && go mod tidy
- name: Check generated files
run: |
status=$(git status --porcelain)
if [ -n "$status" ]; then
echo "you need to run 'go generate ./...' in root and 'examples' dirs and commit the changes"
echo "$status"
git --no-pager diff
exit 1
fi
integration:
runs-on: ubuntu-latest
services:
mysql56:
image: mysql:5.6.35
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
mysql57:
image: mysql:5.7.26
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3307:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
mysql8:
image: mysql:8
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3308:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria:
image: mariadb:10.4 # Temporary to unblock PRs from failing.
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4306:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria102:
image: mariadb:10.2.32
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4307:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria103:
image: mariadb:10.3.13
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4308:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
postgres10:
image: postgres:10
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres11:
image: postgres:11
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5431:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres12:
image: postgres:12.3
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres13:
image: postgres:13.1
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres14:
image: postgres:14
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5434:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres15:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5435:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres16:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5436:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres17:
image: postgres:17
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5437:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gremlin-server:
image: entgo/gremlin-server
ports:
- 8182:8182
options: >-
--health-cmd "netstat -an | grep -q 8182"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: './go.mod'
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run integration tests
working-directory: entc/integration
run: go test -race -count=2 ./...
migration:
runs-on: ubuntu-latest
if: ${{ github.ref != 'refs/heads/master' }}
services:
mysql56:
image: mysql:5.6.35
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
mysql57:
image: mysql:5.7.26
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3307:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
mysql8:
image: mysql:8
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 3308:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria:
image: mariadb:10.4 # Temporary to unblock PRs from failing.
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4306:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria102:
image: mariadb:10.2.32
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4307:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
maria103:
image: mariadb:10.3.13
env:
MYSQL_DATABASE: test
MYSQL_ROOT_PASSWORD: pass
ports:
- 4308:3306
options: >-
--health-cmd "mysqladmin ping -ppass"
--health-interval 10s
--health-start-period 10s
--health-timeout 5s
--health-retries 10
postgres10:
image: postgres:10
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres11:
image: postgres:11
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5431:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres12:
image: postgres:12.3
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres13:
image: postgres:13.1
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres14:
image: postgres:14
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5434:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres15:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5435:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres16:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5436:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
postgres17:
image: postgres:17
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: pass
ports:
- 5437:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gremlin-server:
image: entgo/gremlin-server
ports:
- 8182:8182
options: >-
--health-cmd "netstat -an | grep -q 8182"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: './go.mod'
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Checkout origin/master
run: git checkout origin/master
- name: Run integration on origin/master
working-directory: entc/integration
run: go test -race -count=2 ./...
- name: Checkout previous HEAD
run: git checkout -
- name: Run integration on HEAD
working-directory: entc/integration
run: go test -race -count=2 ./...

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.DS_Store

72
.golangci.yml Normal file
View File

@@ -0,0 +1,72 @@
run:
go: '1.19'
timeout: 5m
linters-settings:
errcheck:
ignore: fmt:.*,Read|Write|Close|Exec,io:Copy
dupl:
threshold: 100
funlen:
lines: 200
statements: 200
goheader:
template: |-
Copyright 2019-present Facebook Inc. All rights reserved.
This source code is licensed under the Apache 2.0 license found
in the LICENSE file in the root directory of this source tree.
linters:
disable-all: true
enable:
- asciicheck
- bodyclose
- dogsled
- dupl
- errcheck
- funlen
- gocritic
# - gofmt; Enable back when upgrading CI to Go 1.20.
- goheader
- gosec
- gosimple
- govet
- ineffassign
- misspell
- staticcheck
- stylecheck
- typecheck
- unconvert
- unused
- whitespace
issues:
exclude-rules:
- path: _test\.go
linters:
- dupl
- funlen
- gosec
- gocritic
- linters:
- unused
source: ent.Schema
- path: dialect/sql/schema
linters:
- dupl
- gosec
- text: "Expect WriteFile permissions to be 0600 or less"
linters:
- gosec
- path: privacy/privacy.go
linters:
- stylecheck
- path: entc/load/schema.go
linters:
- staticcheck
- path: entc/gen/graph.go
linters:
- gocritic
- path: \.go
linters:
- staticcheck
text: SA1019

77
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,77 @@
# Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to make participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when
an individual is representing the project or its community in public spaces.
Examples of representing a project or community include using an official
project e-mail address, posting via an official social media account, or acting
as an appointed representative at an online or offline event. Representation of
a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at <opensource-conduct@fb.com>. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

57
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,57 @@
# Contributing to ent
We want to make contributing to this project as easy and transparent as
possible.
# Project structure
- `dialect` - Contains SQL and Gremlin code used by the generated code.
- `dialect/sql/schema` - Auto migration logic resides there.
- `dialect/sql/sqljson` - JSON extension for SQL.
- `schema` - User schema API.
- `schema/{field, edge, index, mixin}` - provides schema builders API.
- `schema/field/gen` - Templates and codegen for numeric builders.
- `entc` - Codegen of `ent`.
- `entc/load` - `entc` loader API for loading user schemas into a Go objects at runtime.
- `entc/gen` - The actual code generation logic resides in this package (and its `templates` package).
- `integration` - Integration tests for `entc`.
- `privacy` - Runtime code for [privacy layer](https://entgo.io/docs/privacy/).
- `doc` - Documentation code for `entgo.io` (uses [Docusaurus](https://docusaurus.io)).
- `doc/md` - Markdown files for documentation.
- `doc/website` - Website code and assets.
In order to test your documentation changes, run `npm start` from the `doc/website` directory, and open [localhost:3000](http://localhost:3000/).
# Run integration tests
If you touch any file in `entc`, run the following commands in `entc/integration` and 'examples' dirs:
```
go generate ./...
go mod tidy
```
Then, in `entc/integration` run `docker-compose` in order to spin-up all database containers:
```
docker-compose -f docker-compose.yaml up -d
```
Then, run `go test ./...` to run all integration tests.
## Pull Requests
We actively welcome your pull requests.
1. Fork the repo and create your branch from `master`.
2. If you've added code that should be tested, add tests.
3. If you've changed APIs, update the documentation.
4. Ensure the test suite passes.
5. Make sure your code lints.
6. If you haven't already, complete the Contributor License Agreement ("CLA").
## License
By contributing to ent, you agree that your contributions will be licensed
under the LICENSE file in the root directory of this source tree.

202
LICENSE Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
## ent - An Entity Framework For Go
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/entgo_io.svg?style=social&label=Follow%20%40entgo_io)](https://twitter.com/entgo_io)
[![Discord](https://img.shields.io/discord/885059418646003782?label=discord&logo=discord&style=flat-square&logoColor=white)](https://discord.gg/qZmPgTE6RX)
[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md)
<img width="50%"
align="right"
style="display: block; margin:40px auto;"
src="https://s3.eu-central-1.amazonaws.com/entgo.io/assets/gopher_graph.png"/>
Simple, yet powerful entity framework for Go, that makes it easy to build and maintain applications
with large data-models.
- **Schema As Code** - model any database schema as Go objects.
- **Easily Traverse Any Graph** - run queries, aggregations and traverse any graph structure easily.
- **Statically Typed And Explicit API** - 100% statically typed and explicit API using code generation.
- **Multi Storage Driver** - supports MySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite and Gremlin.
- **Extendable** - simple to extend and customize using Go templates.
## Quick Installation
```console
go install entgo.io/ent/cmd/ent@latest
```
For proper installation using [Go modules], visit [entgo.io website][entgo install].
## Docs and Support
The documentation for developing and using ent is available at: https://entgo.io
For discussion and support, [open an issue](https://github.com/ent/ent/issues/new/choose) or join our [Discord server](https://discord.gg/qZmPgTE6RX).
## Developed and Sponsored by the [Atlas](https://atlasgo.io) Team
[![image](https://github.com/user-attachments/assets/2a35b1b7-325f-4844-a2d7-d450c60de0dc)](https://atlasgo.io?utm_source=ent-readme)
## About the Project
The `ent` project was inspired by Ent, an entity framework used internally at Meta (Facebook). It was created by [a8m](https://github.com/a8m) and [alexsn](https://github.com/alexsn) from the [Facebook Connectivity][fbc] team. These days, it is developed and maintained by the [Atlas](https://github.com/ariga/atlas) team, and the roadmap for its v1 release is described [here](https://github.com/ent/ent/issues/46).
## Join the ent Community
Building `ent` would not have been possible without the collective work of our entire community. We maintain a [contributors page](doc/md/contributors.md)
which lists the contributors to this `ent`.
In order to contribute to `ent`, see the [CONTRIBUTING](CONTRIBUTING.md) file for how to go get started.
If your company or your product is using `ent`, please let us know by adding yourself to the [ent users page](https://github.com/ent/ent/wiki/ent-users).
For updates, follow us on Twitter at https://twitter.com/entgo_io
## License
ent is licensed under Apache 2.0 as found in the [LICENSE file](LICENSE).
[entgo install]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent
[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start
[fbc]: https://connectivity.fb.com

54
README_jp.md Normal file
View File

@@ -0,0 +1,54 @@
## ent - Goのエンティティーフレームワーク
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/entgo_io.svg?style=social&label=Follow%20%40entgo_io)](https://twitter.com/entgo_io)
[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md)
<img width="50%"
align="right"
style="display: block; margin:40px auto;"
src="https://s3.eu-central-1.amazonaws.com/entgo.io/assets/gopher_graph.png"/>
シンプルながらもパワフルなGoのエンティティフレームワークであり、大規模なデータモデルを持つアプリケーションを容易に構築・保守できるようにします。
- **Schema As Code(コードとしてのスキーマ)** - あらゆるデータベーススキーマをGoオブジェクトとしてモデル化します。
- **任意のグラフを簡単にトラバースできます** - クエリや集約の実行、任意のグラフ構造の走査を容易に実行できます。
- **100%静的に型付けされた明示的なAPI** - コード生成により、100%静的に型付けされた曖昧さのないAPIを提供します。
- **マルチストレージドライバ** - MySQL、MariaDB、 TiDB、PostgreSQL、CockroachDB、SQLite、Gremlinをサポートしています。
- **拡張性** - Goテンプレートを使用して簡単に拡張、カスタマイズできます。
## クイックインストール
```console
go install entgo.io/ent/cmd/ent@latest
```
[Go modules]を使ったインストールについては、[entgo.ioのWebサイト](https://entgo.io/ja/docs/code-gen/#entc-%E3%81%A8-ent-%E3%81%AE%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%82%92%E4%B8%80%E8%87%B4%E3%81%95%E3%81%9B%E3%82%8B)をご覧ください。
## ドキュメントとサポート
entを開発・使用するためのドキュメントは、こちら: https://entgo.io
議論やサポートについては、[Issueを開く](https://github.com/ent/ent/issues/new/choose)か、gophers Slackの[チャンネル](https://gophers.slack.com/archives/C01FMSQDT53)に参加してください。
## entコミュニティへの参加
`ent`の構築は、コミュニティ全体の協力なしには実現できませんでした。 私たちは、この`ent`の貢献者をリストアップした[contributorsページ](doc/md/contributors.md)を管理しています。
`ent`に貢献するときは、まず[CONTRIBUTING](CONTRIBUTING.md)を参照してください。
もし、あなたの会社や製品で`ent`を利用している場合は、[ent usersページ](https://github.com/ent/ent/wiki/ent-users)に追記する形で、そのことをぜひ教えて下さい。
最新情報については、Twitter(<https://twitter.com/entgo_io>)をフォローしてください。
## プロジェクトについて
`ent`プロジェクトは、私たちが社内で使用しているエンティティフレームワークであるEntからインスピレーションを得ています。
entは、[Facebook Connectivity][fbc]チームの[a8m](https://github.com/a8m)と[alexsn](https://github.com/alexsn)が開発・保守しています。
本番環境では複数のチームやプロジェクトで使用されており、v1リリースまでのロードマップは[こちら](https://github.com/ent/ent/issues/46)に記載されています。
このプロジェクトの動機については[こちら](https://entgo.io/blog/2019/10/03/introducing-ent)をご覧ください。
## ライセンス
entは、[LICENSEファイル](LICENSE)にもある通り、Apache 2.0でライセンスされています。
[entgo instal]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent
[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start
[fbc]: https://connectivity.fb.com

52
README_kr.md Normal file
View File

@@ -0,0 +1,52 @@
## ent - An Entity Framework For Go
[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md) | [한국어](README_kr.md)
<img width="50%"
align="right"
style="display: block; margin:40px auto;"
src="https://s3.eu-central-1.amazonaws.com/entgo.io/assets/gopher_graph.png"/>
간단하지만 강력한 Go용 엔터티 프레임워크로, 대규모 데이터 모델이 포함된 애플리케이션을 쉽게 만들고 유지할 수 있습니다.
- **스키마를 코드로 관리** - 모든 데이터베이스 스키마와 모델을 Go Object로 구현 가능.
- **어떤 그래프든 쉽게 탐색가능** - 쿼리실행, 집계, 그래프구조를 쉽게 탐색 가능.
- **정적 타입 그리고 명시적인 API** - 100% 생성된 코드로, 정적타입과 명시적인 API를 제공.
- **다양한 스토리지 드라이버** - MySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite and Gremlin 를 지원
- **확장성** - Go 템플릿을 이용하여 간단하게 확장, 커스터마이징 가능.
## 빠른 설치
```console
go install entgo.io/ent/cmd/ent@latest
```
[Go modules]을 사용하여 바르게 설치하려면, [entgo.io 웹페이지][entgo install]를 방문해주시길 바랍니다.
## 문서 및 지원
Ent 개발 및 사용에 관한 문서는 여기서 확인할 수 있습니다. : https://entgo.io
토론, 지원을 위해서 [open an issue](https://github.com/ent/ent/issues/new/choose)깃허브 이슈 또는 gophers Slack [채널](https://gophers.slack.com/archives/C01FMSQDT53)에 가입해주세요.
## ent 커뮤니티 가입
ent 커뮤니티의 공동작업이 없었다면, ent를 만들 수 없었을 것입니다. 우리는 기여한 사람들을 [contributors 페이지](doc/md/contributors.md)에 올리고 유지합니다.
ent에 기여하려면 [CONTRIBUTING](CONTRIBUTING.md)에서 시작 방법을 확인해보세요.
프로젝트나 회사에서 ent를 사용중이면, [ent 유저 페이지](https://github.com/ent/ent/wiki/ent-users)에 추가하여 알려주세요.
트위터계정을 팔로우하여 업데이트 소식을 확인하세요. https://twitter.com/entgo_io
## 프로젝트에 관하여
ent프로젝트는 내부적으로 사용하는 엔터티 프레임워크 "Ent"에서 영감을 받았습니다. 개발 및 유지보수는 [a8m](https://github.com/a8m) 및 [alexsn](https://github.com/alexsn)[Facebook Connectivity][fbc] 팀에서 담당합니다. 여러 팀이 프로덕션 환경에서 사용하고 있습니다. v1 릴리즈 로드맵에 대한 설명은 [여기](https://github.com/ent/ent/issues/46)를 클릭해주세요.
프로젝트 동기에 대해 더 궁금하시다면 [여기](https://entgo.io/blog/2019/10/03/introducing-ent)를 클릭해주세요.
## 라이센스
ent 라이센스는 Apache 2.0입니다. [LICENSE file](LICENSE)파일에서도 확인 가능합니다.
[entgo install]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent
[go modules]: https://github.com/golang/go/wiki/Modules#quick-start
[fbc]: https://connectivity.fb.com

44
README_zh.md Normal file
View File

@@ -0,0 +1,44 @@
## ent - 一个强大的Go语言实体框架
[English](README.md) | [中文](README_zh.md) | [日本語](README_jp.md)
<img width="50%"
align="right"
style="display: block; margin:40px auto;"
src="https://s3.eu-central-1.amazonaws.com/entgo.io/assets/gopher_graph.png"/>
ent是一个简单而又功能强大的Go语言实体框架,ent易于构建和维护应用程序与大数据模型。
- **图就是代码** - 将任何数据库表建模为Go对象。
- **轻松地遍历任何图形** - 可以轻松地运行查询、聚合和遍历任何图形结构。
- **静态类型和显式API** - 使用代码生成静态类型和显式API,查询数据更加便捷。
- **多存储驱动程序** - 支持MySQL, PostgreSQL, SQLite 和 Gremlin。
- **可扩展** - 简单地扩展和使用Go模板自定义。
## 快速安装
```console
go install entgo.io/ent/cmd/ent@latest
```
请访问[entgo.io website][entgo instal]以使用[Go modules]进行正确安装。
## 文档和支持
开发和使用ent的文档请参照: https://entgo.io
如要讨论问题和支持, [创建一个issue](https://github.com/ent/ent/issues/new/choose) 或者加入我们的Gopher Slack(Slack软件,类似于论坛)[讨论组](https://gophers.slack.com/archives/C01FMSQDT53)
## 加入 ent 社区
如果你想为`ent`做出贡献, [贡献代码](CONTRIBUTING.md) 中写了如何做出自己的贡献
如果你的公司或者产品在使用`ent`,请让我们知道你已经加入 [ent 用户](https://github.com/ent/ent/wiki/ent-users)
## 关于项目
`ent` 项目灵感来自于Ent,Ent是一个facebook内部使用的一个实体框架项目。 它由 [Facebook Connectivity][fbc] 团队通过 [a8m](https://github.com/a8m) 和 [alexsn](https://github.com/alexsn) 开发和维护
, 它被生产中的多个团队和项目使用。它的v1版本的路线图为 [版本的路线图](https://github.com/ent/ent/issues/46).
关于项目更多的信息 [ent介绍](https://entgo.io/blog/2019/10/03/introducing-ent)。
## 声明
ent使用Apache 2.0协议授权,可以在[LICENSE文件](LICENSE)中找到。
[entgo instal]: https://entgo.io/docs/code-gen/#version-compatibility-between-entc-and-ent
[Go modules]: https://github.com/golang/go/wiki/Modules#quick-start
[fbc]: https://connectivity.fb.com

483
blob.go Normal file
View File

@@ -0,0 +1,483 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ent
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
)
// Blob defines the interface for blob storage operations.
// Implementations should return [io/fs.ErrNotExist] (or an error wrapping it)
// from NewReader when the requested key does not exist.
//
// Single-row SQL create builders write blob data to external storage before
// inserting the database row. If the row insertion fails (for example, due to
// a constraint violation), generated code attempts to delete the just-written
// blobs — but only those no database row references. See [BlobRefChecker].
type Blob interface {
// NewReader opens a reader for the given key.
NewReader(ctx context.Context, key string) (io.ReadCloser, error)
// NewWriter opens a writer for the given key.
NewWriter(ctx context.Context, key string) (io.WriteCloser, error)
// Delete removes the blob at the given key.
// Implementations should return nil (not an error) if the key does not exist.
Delete(ctx context.Context, key string) error
// Close releases any resources held by the bucket.
Close() error
}
// BlobOpener is a function that opens a [Blob] bucket for the given field name.
type BlobOpener func(context.Context, string) (Blob, error)
// BlobKey identifies a blob in storage by field name and key.
type BlobKey struct {
Field string
Key string
}
// BlobRefChecker reports which of the given blob keys rows in the database hold.
//
// Every path that deletes from storage consults it first, because a blob key is
// not private to the row that wrote it. With content-addressed keys — the default
// — identical content always yields an identical key, so the object written ahead
// of a failed insert may be the very object an existing row points at, and two
// rows with the same content share one object. A key is safe to remove only once
// no row holds it.
//
// Keys are looked up per field, which takes each blob field to have its own
// bucket: the model [BlobOpener] describes. Fields pointing at one shared bucket
// also share objects, and cleanup cannot see across them.
//
// The lookup always runs while the mutation's statement (and transaction) is still
// open, timed so that the table already reads the way the mutation will leave it:
// before the INSERT for blobs written ahead of a row, and after the UPDATE or
// DELETE for keys the mutation gives up ([BlobUpdateResult.Cleanup],
// [BlobDeleteResult.Cleanup]). Nothing has to be excluded from the result that way.
//
// A row inserted concurrently between the lookup and the delete is not observed;
// content-addressed storage needs reference counting or a sweeper to close that
// window entirely.
type BlobRefChecker interface {
ReferencedBlobKeys(ctx context.Context, keys []BlobKey) ([]BlobKey, error)
}
// BlobQuerier queries existing blob keys from the database.
// [Blobs.Update] passes the mutated field names; [Blobs.Delete] passes nil
// to indicate all fields should be queried.
type BlobQuerier interface {
BlobRefChecker
QueryBlobKeys(ctx context.Context, fields []string) ([]BlobKey, error)
}
// BlobUpdateResult holds post-update blob operations.
type BlobUpdateResult struct {
Rollback BlobOp // Deletes newly-written blobs. Call on SQL failure.
blobs *Blobs
orphaned []BlobKey
}
// Cleanup reports which of the blobs the update replaced no row holds any more, and
// returns the [BlobOp] that removes them from storage.
//
// Call it once the UPDATE has run and before the transaction commits — the reference
// check has to see the row on its new key, and it cannot run on a transaction that has
// already settled. Invoke the returned op after the commit.
func (r *BlobUpdateResult) Cleanup(ctx context.Context) (BlobOp, error) {
if r.blobs == nil {
return noOp, nil
}
return r.blobs.deleteOp(ctx, r.orphaned)
}
// BlobDeleteResult holds the blob keys of the rows a delete mutation removes.
type BlobDeleteResult struct {
blobs *Blobs
keys []BlobKey
}
// Cleanup reports which of those blobs no row holds any more, and returns the [BlobOp]
// that removes them from storage.
//
// Call it once the DELETE has run and before the transaction commits — the reference
// check has to see the rows gone, and it cannot run on a transaction that has already
// settled. Invoke the returned op after the commit.
func (r *BlobDeleteResult) Cleanup(ctx context.Context) (BlobOp, error) {
if r.blobs == nil {
return noOp, nil
}
return r.blobs.deleteOp(ctx, r.keys)
}
// BlobOp is a deferred blob storage operation (e.g. rollback or commit).
type BlobOp func(context.Context) error
// BlobKeyFunc generates a storage key for a blob from its content.
type BlobKeyFunc func(context.Context, []byte) (string, error)
// Blobs orchestrates blob storage operations for a single mutation.
// Use [NewBlobs] to create, then call [Blobs.Set] or [Blobs.SetCleared]
// for each blob field, then [Blobs.Create] or [Blobs.Update].
type Blobs struct {
opener BlobOpener
inputs []blobInput
refs BlobRefChecker
}
type blobInput struct {
field string
data []byte
newKey BlobKeyFunc
apply func(string)
cleared bool
clear func()
}
// NewBlobs creates a blob orchestrator for the given opener.
func NewBlobs(opener BlobOpener) *Blobs {
return &Blobs{opener: opener}
}
// Set adds a blob field to be written. The apply callback is called with
// the generated key to set it on the SQL spec and node.
func (b *Blobs) Set(f string, data []byte, key BlobKeyFunc, apply func(string)) {
b.inputs = append(b.inputs, blobInput{field: f, data: data, newKey: key, apply: apply})
}
// SetCleared marks a blob field as cleared. The clear callback should
// remove the key column from the SQL spec.
func (b *Blobs) SetCleared(f string, clear func()) {
b.inputs = append(b.inputs, blobInput{field: f, cleared: true, clear: clear})
}
// Create prepares inputs, writes blobs, and returns a rollback [BlobOp].
// refs keeps the rollback from deleting an object that rows in the database
// already reference; a nil refs skips that check.
func (b *Blobs) Create(ctx context.Context, refs BlobRefChecker) (BlobOp, error) {
b.refs = refs
writes, err := b.prepare(ctx)
if err != nil {
return nil, err
}
return b.write(ctx, writes)
}
// Update prepares inputs, queries old keys, writes new blobs, and returns
// a [BlobUpdateResult] for post-SQL handling.
func (b *Blobs) Update(ctx context.Context, q BlobQuerier) (*BlobUpdateResult, error) {
if len(b.inputs) == 0 {
return noopBlobResult, nil
}
b.refs = q
writes, err := b.prepare(ctx)
if err != nil {
return nil, err
}
mutated := make([]string, len(b.inputs))
cleared := make(map[string]bool)
for i := range b.inputs {
mutated[i] = b.inputs[i].field
if b.inputs[i].cleared {
cleared[b.inputs[i].field] = true
}
}
keys, err := q.QueryBlobKeys(ctx, mutated)
if err != nil {
return nil, fmt.Errorf("querying old blob keys: %w", err)
}
// Build a set of old keys per field to detect unchanged blobs.
oldKeys := make(map[string]string, len(keys))
for _, k := range keys {
oldKeys[k.Field] = k.Key
}
// Filter out writes where the key is unchanged (same content). Keep this in
// its own slice — the orphan collection below reads the unfiltered writes.
filtered := make([]blobWrite, 0, len(writes))
for _, wr := range writes {
if oldKeys[wr.Field] == wr.Key {
continue
}
filtered = append(filtered, wr)
}
rollback, err := b.write(ctx, filtered)
if err != nil {
return nil, err
}
// Collect orphaned blobs: old keys for fields that changed or were cleared.
var orphaned []BlobKey
for _, k := range keys {
if cleared[k.Field] {
orphaned = append(orphaned, k)
continue
}
for _, wr := range writes {
if wr.Field == k.Field && wr.Key != k.Key {
orphaned = append(orphaned, k)
break
}
}
}
// The orphaned keys are resolved by BlobUpdateResult.Cleanup rather than here:
// until the UPDATE runs, this row still holds them.
return &BlobUpdateResult{
Rollback: rollback,
blobs: b,
orphaned: orphaned,
}, nil
}
// Delete collects the blob keys held by the rows the mutation matches. Call it
// before the DELETE runs; [BlobDeleteResult.Cleanup] resolves what to remove from
// storage once the rows are gone. Use for delete mutations.
func (b *Blobs) Delete(ctx context.Context, q BlobQuerier) (*BlobDeleteResult, error) {
b.refs = q
keys, err := q.QueryBlobKeys(ctx, nil)
if err != nil {
return nil, err
}
return &BlobDeleteResult{blobs: b, keys: keys}, nil
}
type blobWrite struct {
BlobKey
data []byte
}
func (b *Blobs) prepare(ctx context.Context) ([]blobWrite, error) {
var writes []blobWrite
for _, inp := range b.inputs {
if inp.cleared {
inp.clear()
continue
}
k, err := inp.newKey(ctx, inp.data)
if err != nil {
return nil, fmt.Errorf("generating blob key for %s: %w", inp.field, err)
}
if inp.apply != nil {
inp.apply(k)
}
writes = append(writes, blobWrite{
BlobKey: BlobKey{Field: inp.field, Key: k},
data: inp.data,
})
}
return writes, nil
}
func (b *Blobs) write(ctx context.Context, writes []blobWrite) (BlobOp, error) {
if len(writes) == 0 {
return noOp, nil
}
w := NewBlobStore(b.opener)
var written []BlobKey
for _, wr := range writes {
if err := w.write(ctx, wr.Field, wr.Key, wr.data); err != nil {
// A failed write may still have left an object behind, so clean it up
// with the rest — under the same reference check.
written = append(written, wr.BlobKey)
errs := []error{fmt.Errorf("writing blob for %s: %w", wr.Field, err), w.Close()}
switch rollback, rerr := b.deleteOp(ctx, written); {
case rerr != nil:
errs = append(errs, rerr)
default:
errs = append(errs, rollback(ctx))
}
return nil, errors.Join(errs...)
}
written = append(written, wr.BlobKey)
}
if err := w.Close(); err != nil {
return nil, err
}
return b.deleteOp(ctx, written)
}
// deleteOp returns a [BlobOp] that removes keys from storage, skipping any key
// that rows outside this mutation still hold.
//
// The lookup happens here rather than inside the returned op: the op runs after
// the statement — or the whole transaction — has settled, when the mutation's own
// view of the table is no longer reachable.
func (b *Blobs) deleteOp(ctx context.Context, keys []BlobKey) (BlobOp, error) {
if len(keys) == 0 {
return noOp, nil
}
keys, err := b.unreferenced(ctx, keys)
if err != nil {
return nil, err
}
if len(keys) == 0 {
return noOp, nil
}
return func(ctx context.Context) error {
s := NewBlobStore(b.opener)
var errs []error
for _, k := range keys {
if err := s.delete(ctx, k.Field, k.Key); err != nil {
errs = append(errs, err)
}
}
if err := s.Close(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}, nil
}
// unreferenced returns the distinct keys no surviving row holds, dropping the
// ones still in use. See [BlobRefChecker] for why a key can be shared at all.
func (b *Blobs) unreferenced(ctx context.Context, keys []BlobKey) ([]BlobKey, error) {
if b.refs == nil {
return dedupBlobKeys(keys), nil
}
referenced, err := b.refs.ReferencedBlobKeys(ctx, keys)
if err != nil {
return nil, fmt.Errorf("checking blob key references: %w", err)
}
if len(referenced) == 0 {
return dedupBlobKeys(keys), nil
}
held := make(map[BlobKey]bool, len(referenced))
for _, k := range referenced {
held[k] = true
}
unreferenced := make([]BlobKey, 0, len(keys))
for _, k := range dedupBlobKeys(keys) {
if !held[k] {
unreferenced = append(unreferenced, k)
}
}
return unreferenced, nil
}
func dedupBlobKeys(keys []BlobKey) []BlobKey {
seen := make(map[BlobKey]bool, len(keys))
distinct := make([]BlobKey, 0, len(keys))
for _, k := range keys {
if seen[k] {
continue
}
seen[k] = true
distinct = append(distinct, k)
}
return distinct
}
var (
noOp = func(context.Context) error { return nil }
noopBlobResult = &BlobUpdateResult{Rollback: noOp}
)
// BlobReader returns a reader for the given key from the blob bucket.
// The returned reader closes both the underlying reader and the bucket.
// Returns nil, nil if the blob does not exist (fs.ErrNotExist).
func BlobReader(ctx context.Context, b Blob, key string) (io.ReadCloser, error) {
switch r, err := b.NewReader(ctx, key); {
case errors.Is(err, fs.ErrNotExist):
return nil, b.Close()
case err != nil:
return nil, errors.Join(err, b.Close())
default:
return &blobReadCloser{ReadCloser: r, bucket: b}, nil
}
}
type blobReadCloser struct {
io.ReadCloser
bucket Blob
}
func (r *blobReadCloser) Close() error {
return errors.Join(r.ReadCloser.Close(), r.bucket.Close())
}
// BlobStore manages blob bucket lifecycles for read, write, and delete operations.
// It lazily opens buckets per field and reuses them for subsequent operations.
type BlobStore struct {
opener BlobOpener
buckets map[string]Blob
}
// NewBlobStore creates a store that uses opener to lazily open buckets.
func NewBlobStore(opener BlobOpener) *BlobStore {
return &BlobStore{buckets: make(map[string]Blob), opener: opener}
}
// Close closes all open buckets.
func (s *BlobStore) Close() error {
var errs []error
for _, b := range s.buckets {
errs = append(errs, b.Close())
}
return errors.Join(errs...)
}
// write writes data to the blob at key for the given field.
func (s *BlobStore) write(ctx context.Context, field, key string, data []byte) error {
b, err := s.bucket(ctx, field)
if err != nil {
return err
}
wr, err := b.NewWriter(ctx, key)
if err != nil {
return err
}
if _, err := wr.Write(data); err != nil {
// Leave the key alone — it may be an object other rows reference. The
// caller cleans up through [Blobs.deleteOp], which checks for references.
return errors.Join(err, wr.Close())
}
return wr.Close()
}
// delete removes the blob at key for the given field.
func (s *BlobStore) delete(ctx context.Context, field, key string) error {
b, err := s.bucket(ctx, field)
if err != nil {
return err
}
return b.Delete(ctx, key)
}
// Read reads the blob at key for the given field.
// Returns nil, nil if the blob does not exist (fs.ErrNotExist).
func (s *BlobStore) Read(ctx context.Context, field, key string) ([]byte, error) {
b, err := s.bucket(ctx, field)
if err != nil {
return nil, err
}
rc, err := b.NewReader(ctx, key)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
data, err := io.ReadAll(rc)
if closeErr := rc.Close(); closeErr != nil && err == nil {
err = closeErr
}
return data, err
}
func (s *BlobStore) bucket(ctx context.Context, field string) (Blob, error) {
if b, ok := s.buckets[field]; ok {
return b, nil
}
if s.opener == nil {
return nil, errors.New("ent: blob storage not configured (missing WithBlobOpeners)")
}
b, err := s.opener(ctx, field)
if err != nil {
return nil, err
}
s.buckets[field] = b
return b, nil
}

26
cmd/ent/ent.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package main
import (
"log"
"entgo.io/ent/cmd/internal/base"
"github.com/spf13/cobra"
)
func main() {
log.SetFlags(0)
cmd := &cobra.Command{Use: "ent"}
cmd.AddCommand(
base.NewCmd(),
base.DescribeCmd(),
base.GenerateCmd(),
base.InitCmd(),
base.SchemaCmd(),
)
cobra.CheckErr(cmd.Execute())
}

39
cmd/ent/ent_test.go Normal file
View File

@@ -0,0 +1,39 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package main
import (
"bytes"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestCmd(t *testing.T) {
defer os.RemoveAll("ent")
cmd := exec.Command("go", "run", "entgo.io/ent/cmd/ent", "new", "User")
stderr := bytes.NewBuffer(nil)
cmd.Stderr = stderr
require.NoError(t, cmd.Run())
require.Zero(t, stderr.String())
cmd = exec.Command("go", "run", "entgo.io/ent/cmd/ent", "new", "User")
require.Error(t, cmd.Run())
_, err := os.Stat("ent/generate.go")
require.NoError(t, err)
_, err = os.Stat("ent/schema/user.go")
require.NoError(t, err)
cmd = exec.Command("go", "run", "entgo.io/ent/cmd/ent", "generate", "./ent/schema")
stderr = bytes.NewBuffer(nil)
cmd.Stderr = stderr
require.NoError(t, cmd.Run())
require.Zero(t, stderr.String())
_, err = os.Stat("ent/user.go")
require.NoError(t, err)
}

41
cmd/entc/entc.go Normal file
View File

@@ -0,0 +1,41 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package main
import (
"bytes"
"log"
"os"
"path/filepath"
"entgo.io/ent/cmd/internal/base"
"entgo.io/ent/entc/gen"
"github.com/spf13/cobra"
)
func main() {
log.SetFlags(0)
cmd := &cobra.Command{Use: "entc"}
cmd.AddCommand(
base.NewCmd(),
base.DescribeCmd(),
base.GenerateCmd(migrate),
base.InitCmd(),
)
_ = cmd.Execute()
}
func migrate(c *gen.Config) {
var (
target = filepath.Join(c.Target, "generate.go")
oldCmd = []byte("entgo.io/ent/cmd/entc")
)
buf, err := os.ReadFile(target)
if err != nil || !bytes.Contains(buf, oldCmd) {
return
}
_ = os.WriteFile(target, bytes.ReplaceAll(buf, oldCmd, []byte("entgo.io/ent/cmd/ent")), 0644)
}

35
cmd/entc/entc_test.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package main
import (
"bytes"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/require"
)
func TestCmd(t *testing.T) {
defer os.RemoveAll("ent")
cmd := exec.Command("go", "run", "entgo.io/ent/cmd/entc", "new", "User")
stderr := bytes.NewBuffer(nil)
cmd.Stderr = stderr
require.NoError(t, cmd.Run(), stderr.String())
_, err := os.Stat("ent/generate.go")
require.NoError(t, err)
_, err = os.Stat("ent/schema/user.go")
require.NoError(t, err)
cmd = exec.Command("go", "run", "entgo.io/ent/cmd/entc", "generate", "./ent/schema")
stderr = bytes.NewBuffer(nil)
cmd.Stderr = stderr
require.NoError(t, cmd.Run(), stderr.String())
_, err = os.Stat("ent/user.go")
require.NoError(t, err)
}

107
cmd/entfix/entfix.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/schema"
"entgo.io/ent/entc/gen"
"github.com/alecthomas/kong"
)
type (
// App configures the entfix CLI.
App struct {
GlobalID GlobalID `cmd:"" name:"globalid" help:"Migrate unique global id ent_types to ent global feature"`
}
// GlobalID represents the 'entfix globalid' command.
GlobalID struct {
Dialect string `name:"dialect" help:"Database dialect" required:"" enum:"mysql,postgres,sqlite3"`
DSN string `name:"dsn" help:"Data source name" required:""`
Path string `name:"path" help:"Path to the generated ent code" required:""`
}
)
func main() {
// Ensure to stop execution on Interrupt signal.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
defer stop()
app := kong.Parse(
new(App),
kong.BindTo(ctx, (*context.Context)(nil)),
kong.UsageOnError(),
)
app.FatalIfErrorf(func() error {
err := app.Run()
if err := context.Cause(ctx); err != nil {
return err
}
if errors.Is(err, context.Canceled) {
return nil
}
return err
}())
}
func (cmd *GlobalID) Run(ctx context.Context) error {
fmt.Print(`IMPORTANT INFORMATION
'entfix globalid' will convert the allocated id ranges for your nodes from the
database stored 'ent_types' table to the new static configuration on the ent
schema itself.
Please note, that the 'ent_types' table might differ between different environments
where your app is deployed. This is especially true if you are using
auto-migration instead of versioned migrations.
Please check, that all 'ent_types' tables for all deployments are equal!
Only 'yes' will be accepted to approve.
Enter a value: `)
switch c, err := bufio.NewReader(os.Stdin).ReadString('\n'); {
case err != nil:
return err
case strings.TrimSpace(c) != "yes":
fmt.Println("\nAborted.")
return nil
}
db, err := sql.Open(cmd.Dialect, cmd.DSN)
if err != nil {
return err
}
rows := &sql.Rows{}
query, args := sql.Dialect(cmd.Dialect).
Select("type").
From(sql.Table(schema.TypeTable)).
OrderBy(sql.Asc("id")).
Query()
if err := db.Query(ctx, query, args, rows); err != nil {
return err
}
defer rows.Close()
var ts []string
if err := sql.ScanSlice(rows, &ts); err != nil {
return err
}
is := make(gen.IncrementStarts, len(ts))
for i, t := range ts {
is[t] = int64(i << 32)
}
if err := is.WriteToDisk(cmd.Path); err != nil {
return err
}
fmt.Println("\nSuccess! Please run code generation to complete the process.")
return nil
}

89
cmd/entfix/entfix_test.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/schema"
"github.com/stretchr/testify/require"
_ "github.com/mattn/go-sqlite3"
)
func TestEntfix(t *testing.T) {
ctx := context.Background()
stdout, err := os.CreateTemp(t.TempDir(), "")
require.NoError(t, err)
oldStdout := os.Stdout
os.Stdout = stdout
t.Cleanup(func() {
os.Stdout = oldStdout
require.NoError(t, stdout.Close())
})
cli := &GlobalID{
Dialect: dialect.SQLite,
DSN: fmt.Sprintf("file:%s?mode=memory&cache=shared&_fk=1", t.Name()),
Path: t.TempDir(),
}
// Prints details, requires confirmation.
os.Stdin = mockStdin(t, "no\n")
require.NoError(t, cli.Run(ctx))
buf, err := os.ReadFile(stdout.Name())
require.NoError(t, err)
require.True(t, strings.HasPrefix(string(buf), "IMPORTANT INFORMATION\n\n"))
require.True(t, strings.HasSuffix(string(buf), "Aborted.\n"))
f, err := os.Open(cli.Path)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, f.Close()) })
_, err = f.Readdirnames(1)
require.ErrorIs(t, err, io.EOF)
// If approved, converts the 'ent_types' table.
db, err := sql.Open(cli.Dialect, cli.DSN)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, db.Close()) })
drv := entsql.OpenDB(dialect.SQLite, db)
m, err := schema.NewMigrate(drv, schema.WithGlobalUniqueID(true))
require.NoError(t, err)
require.NoError(t, m.Create(ctx))
_, err = db.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (type) VALUES ('z'), ('y'), ('a'), ('b')", schema.TypeTable))
require.NoError(t, err)
os.Stdin = mockStdin(t, "yes\n")
require.NoError(t, cli.Run(ctx))
buf, err = os.ReadFile(stdout.Name())
require.NoError(t, err)
require.True(t, strings.HasSuffix(string(buf), "Success! Please run code generation to complete the process.\n"))
c, err := os.ReadFile(filepath.Join(cli.Path, "internal", "globalid.go"))
require.NoError(t, err)
require.Contains(t,
string(c),
fmt.Sprintf(`const IncrementStarts = "{\"a\":%d,\"b\":%d,\"y\":%d,\"z\":%d}"`, 2<<32, 3<<32, 1<<32, 0),
)
}
func mockStdin(t *testing.T, content string) *os.File {
t.Helper()
stdin, err := os.CreateTemp(t.TempDir(), "")
require.NoError(t, err)
_, err = stdin.WriteString(content)
require.NoError(t, err)
_, err = stdin.Seek(0, 0)
require.NoError(t, err)
oldStdin := os.Stdin
t.Cleanup(func() {
os.Stdin = oldStdin
require.NoError(t, stdin.Close())
})
return stdin
}

32
cmd/entfix/go.mod Normal file
View File

@@ -0,0 +1,32 @@
module entgo.io/ent/cmd/entfix
go 1.23
require (
entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6
github.com/alecthomas/kong v1.6.1
github.com/go-sql-driver/mysql v1.8.1
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/stretchr/testify v1.8.2
)
require (
ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/hcl/v2 v2.23.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

67
cmd/entfix/go.sum Normal file
View File

@@ -0,0 +1,67 @@
ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1 h1:tqUmxmE7r2qqWmgczyNZNOyhzXLqmdUpRhVSuvCUqTU=
ariga.io/atlas v0.27.1-0.20240912191503-92195304dbe1/go.mod h1:cpxrs2J9HTrjMrfNKFmUpONY9L0vzAHZ1pY++nTtVw0=
entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6 h1:7H+NesJeDdcNtZxp0s5Ip/IYLkuUfoRFTIAC0fGcezg=
entgo.io/ent v0.14.2-0.20250119141711-a0182c96ebb6/go.mod h1:nPwpHTcyAjBMt+hT6q7Y/SdF41e6IJudQPbTj1P5nYY=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.6.1 h1:/7bVimARU3uxPD0hbryPE8qWrS3Oz3kPQoxA/H2NKG8=
github.com/alecthomas/kong v1.6.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

360
cmd/internal/base/base.go Normal file
View File

@@ -0,0 +1,360 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
// Package base defines shared basic pieces of the ent command.
package base
import (
"bytes"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
"unicode"
"entgo.io/ent/cmd/internal/printer"
"entgo.io/ent/dialect/sql/schema"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"entgo.io/ent/schema/field"
"github.com/spf13/cobra"
)
// IDType is a custom ID implementation for pflag.
type IDType field.Type
// Set implements the Set method of the flag.Value interface.
func (t *IDType) Set(s string) error {
switch s {
case field.TypeInt.String():
*t = IDType(field.TypeInt)
case field.TypeInt64.String():
*t = IDType(field.TypeInt64)
case field.TypeUint.String():
*t = IDType(field.TypeUint)
case field.TypeUint64.String():
*t = IDType(field.TypeUint64)
case field.TypeString.String():
*t = IDType(field.TypeString)
default:
return fmt.Errorf("invalid type %q", s)
}
return nil
}
// Type returns the type representation of the id option for help command.
func (IDType) Type() string {
return fmt.Sprintf("%v", []field.Type{
field.TypeInt,
field.TypeInt64,
field.TypeUint,
field.TypeUint64,
field.TypeString,
})
}
// String returns the default value for the help command.
func (IDType) String() string {
return field.TypeInt.String()
}
// InitCmd returns the init command for ent/c packages.
func InitCmd() *cobra.Command {
c := NewCmd()
c.Use = "init [flags] [schemas]"
c.Short = "initialize an environment with zero or more schemas"
c.Example = examples(
"ent init Example",
"ent init --target entv1/schema User Group",
"ent init --template ./path/to/file.tmpl User",
)
c.Deprecated = `use "ent new" instead`
return c
}
// NewCmd returns the new command for ent/c packages.
func NewCmd() *cobra.Command {
var target, tmplPath string
cmd := &cobra.Command{
Use: "new [flags] [schemas]",
Short: "initialize a new environment with zero or more schemas",
Example: examples(
"ent new Example",
"ent new --target entv1/schema User Group",
"ent new --template ./path/to/file.tmpl User",
),
Args: func(_ *cobra.Command, names []string) error {
for _, name := range names {
if !unicode.IsUpper(rune(name[0])) {
return errors.New("schema names must begin with uppercase")
}
}
return nil
},
Run: func(cmd *cobra.Command, names []string) {
var (
err error
tmpl *template.Template
)
if tmplPath != "" {
tmpl = template.New(filepath.Base(tmplPath)).Funcs(gen.Funcs)
tmpl, err = tmpl.ParseFiles(tmplPath)
} else {
tmpl = template.New("schema").Funcs(gen.Funcs)
tmpl, err = tmpl.Parse(defaultTemplate)
}
if err != nil {
log.Fatalln(fmt.Errorf("ent/new: could not parse template %w", err))
}
if err := newEnv(target, names, tmpl); err != nil {
log.Fatalln(fmt.Errorf("ent/new: %w", err))
}
},
}
cmd.Flags().StringVar(&target, "target", defaultSchema, "target directory for schemas")
cmd.Flags().StringVar(&tmplPath, "template", "", "template to use for new schemas")
return cmd
}
// DescribeCmd returns the describe command for ent/c packages.
func DescribeCmd() *cobra.Command {
return &cobra.Command{
Use: "describe [flags] path",
Short: "print a description of the graph schema",
Example: examples(
"ent describe ./ent/schema",
"ent describe github.com/a8m/x",
),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, path []string) {
graph, err := entc.LoadGraph(path[0], &gen.Config{})
if err != nil {
log.Fatalln(err)
}
printer.Fprint(os.Stdout, graph)
},
}
}
// GenerateCmd returns the generate command for ent/c packages.
func GenerateCmd(postRun ...func(*gen.Config)) *cobra.Command {
var (
cfg gen.Config
storage string
features []string
templates []string
idtype = IDType(field.TypeInt)
cmd = &cobra.Command{
Use: "generate [flags] path",
Short: "generate go code for the schema directory",
Example: examples(
"ent generate ./ent/schema",
"ent generate github.com/a8m/x",
),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, path []string) {
opts := []entc.Option{
entc.Storage(storage),
entc.FeatureNames(features...),
}
for _, tmpl := range templates {
typ := "dir"
if parts := strings.SplitN(tmpl, "=", 2); len(parts) > 1 {
typ, tmpl = parts[0], parts[1]
}
switch typ {
case "dir":
opts = append(opts, entc.TemplateDir(tmpl))
case "file":
opts = append(opts, entc.TemplateFiles(tmpl))
case "glob":
opts = append(opts, entc.TemplateGlob(tmpl))
default:
log.Fatalln("unsupported template type", typ)
}
}
// If the target directory is not inferred from
// the schema path, resolve its package path.
if cfg.Target != "" {
pkgPath, err := PkgPath(DefaultConfig, cfg.Target)
if err != nil {
log.Fatalln(err)
}
cfg.Package = pkgPath
}
cfg.IDType = &field.TypeInfo{Type: field.Type(idtype)}
if err := entc.Generate(path[0], &cfg, opts...); err != nil {
log.Fatalln(err)
}
for _, fn := range postRun {
fn(&cfg)
}
},
}
)
cmd.Flags().Var(&idtype, "idtype", "type of the id field")
cmd.Flags().StringVar(&storage, "storage", "sql", "storage driver to support in codegen")
cmd.Flags().StringVar(&cfg.Header, "header", "", "override codegen header")
cmd.Flags().StringVar(&cfg.Target, "target", "", "target directory for codegen")
cmd.Flags().StringSliceVarP(&features, "feature", "", nil, "extend codegen with additional features")
cmd.Flags().StringSliceVarP(&templates, "template", "", nil, "external templates to execute")
// The --idtype flag predates the field.<Type>("id") option.
// See, https://entgo.io/docs/schema-fields#id-field.
cobra.CheckErr(cmd.Flags().MarkHidden("idtype"))
return cmd
}
// SchemaCmd returns DDL to use Ent as an Atlas schema loader.
func SchemaCmd() *cobra.Command {
var (
cfg gen.Config
dlct, version string
features, buildTags []string
hashSymbols bool
cmd = &cobra.Command{
Use: "schema [flags] path",
Short: "dump the DDL for the schema directory",
Example: examples(
"ent schema ./ent/schema --dialect mysql --version 5.6",
"ent schema ./ent/schema --dialect sqlite3",
"ent schema github.com/a8m/x --dialect postgres --version 15",
),
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, path []string) {
for _, o := range []entc.Option{
entc.FeatureNames(features...),
entc.BuildTags(buildTags...),
} {
if err := o(&cfg); err != nil {
log.Fatalln(err)
}
}
// If the target directory is not inferred from
// the schema path, resolve its package path.
if cfg.Target != "" {
pkgPath, err := PkgPath(DefaultConfig, cfg.Target)
if err != nil {
log.Fatalln(err)
}
cfg.Package = pkgPath
}
g, err := entc.LoadGraph(path[0], &cfg)
if err != nil {
log.Fatalln(err)
}
t, err := g.Tables()
if err != nil {
log.Fatalln(err)
}
v, err := g.Views()
if err != nil {
log.Fatalln(err)
}
ddl, err := schema.DDL(cmd.Context(), schema.DDLArgs{
Dialect: dlct,
Version: version,
HashSymbols: hashSymbols,
Tables: append(t, v...),
})
if err != nil {
log.Fatalln(err)
}
fmt.Println(ddl)
},
}
)
cmd.Flags().StringVar(&dlct, "dialect", "", "database dialect to use")
cmd.Flags().StringVar(&version, "version", "", "database version to assume")
cmd.Flags().StringSliceVarP(&features, "feature", "", nil, "extend codegen with additional features")
cmd.Flags().StringSliceVarP(&buildTags, "build-tags", "", nil, "go build tags to use when loading the schema graph")
cmd.Flags().BoolVar(&hashSymbols, "hash-symbols", false, "whether to hash long symbols")
cobra.CheckErr(cmd.MarkFlagRequired("dialect"))
return cmd
}
// newEnv create a new environment for ent codegen.
func newEnv(target string, names []string, tmpl *template.Template) error {
if err := createDir(target); err != nil {
return fmt.Errorf("create dir %s: %w", target, err)
}
for _, name := range names {
if err := gen.ValidSchemaName(name); err != nil {
return fmt.Errorf("new schema %s: %w", name, err)
}
if fileExists(target, name) {
return fmt.Errorf("new schema %s: already exists", name)
}
b := bytes.NewBuffer(nil)
if err := tmpl.Execute(b, name); err != nil {
return fmt.Errorf("executing template %s: %w", name, err)
}
newFileTarget := filepath.Join(target, strings.ToLower(name+".go"))
if err := os.WriteFile(newFileTarget, b.Bytes(), 0644); err != nil {
return fmt.Errorf("writing file %s: %w", newFileTarget, err)
}
}
return nil
}
func createDir(target string) error {
_, err := os.Stat(target)
if err == nil || !os.IsNotExist(err) {
return err
}
if err := os.MkdirAll(target, os.ModePerm); err != nil {
return fmt.Errorf("creating schema directory: %w", err)
}
if target != defaultSchema {
return nil
}
if err := os.WriteFile("ent/generate.go", []byte(genFile), 0644); err != nil {
return fmt.Errorf("creating generate.go file: %w", err)
}
return nil
}
func fileExists(target, name string) bool {
var _, err = os.Stat(filepath.Join(target, strings.ToLower(name+".go")))
return err == nil
}
const (
// default schema package path.
defaultSchema = "ent/schema"
// ent/generate.go file used for "go generate" command.
genFile = "package ent\n\n//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema\n"
// schema template for the "init" command.
defaultTemplate = `package schema
import "entgo.io/ent"
// {{ . }} holds the schema definition for the {{ . }} entity.
type {{ . }} struct {
ent.Schema
}
// Fields of the {{ . }}.
func ({{ . }}) Fields() []ent.Field {
return nil
}
// Edges of the {{ . }}.
func ({{ . }}) Edges() []ent.Edge {
return nil
}
`
)
// examples formats the given examples to the cli.
func examples(ex ...string) string {
for i := range ex {
ex[i] = " " + ex[i] // indent each row with 2 spaces.
}
return strings.Join(ex, "\n")
}

View File

@@ -0,0 +1,55 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package base
import (
"fmt"
"os"
"path"
"path/filepath"
"golang.org/x/tools/go/packages"
)
// DefaultConfig for loading Go base.
var DefaultConfig = &packages.Config{Mode: packages.NeedName}
// PkgPath returns the Go package name for given target path.
// Even if the existing path is not exist yet in the filesystem.
//
// If base.Config is nil, DefaultConfig will be used to load base.
func PkgPath(config *packages.Config, target string) (string, error) {
if config == nil {
config = DefaultConfig
}
pathCheck, err := filepath.Abs(target)
if err != nil {
return "", err
}
var parts []string
if _, err := os.Stat(pathCheck); os.IsNotExist(err) {
parts = append(parts, filepath.Base(pathCheck))
pathCheck = filepath.Dir(pathCheck)
}
// Try maximum 2 directories above the given
// target to find the root package or module.
for i := 0; i < 2; i++ {
pkgs, err := packages.Load(config, pathCheck)
if err != nil {
return "", fmt.Errorf("load package info: %w", err)
}
if len(pkgs) == 0 || len(pkgs[0].Errors) != 0 {
parts = append(parts, filepath.Base(pathCheck))
pathCheck = filepath.Dir(pathCheck)
continue
}
pkgPath := pkgs[0].PkgPath
for j := len(parts) - 1; j >= 0; j-- {
pkgPath = path.Join(pkgPath, parts[j])
}
return pkgPath, nil
}
return "", fmt.Errorf("root package or module was not found for: %s", target)
}

View File

@@ -0,0 +1,49 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package base
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/tools/go/packages/packagestest"
)
func TestPkgPath(t *testing.T) { packagestest.TestAll(t, testPkgPath) }
func testPkgPath(t *testing.T, x packagestest.Exporter) {
e := packagestest.Export(t, x, []packagestest.Module{
{
Name: "golang.org/x",
Files: map[string]any{
"x.go": "package x",
"y/y.go": "package y",
},
},
})
defer e.Cleanup()
e.Config.Dir = filepath.Dir(e.File("golang.org/x", "x.go"))
target := filepath.Join(e.Config.Dir, "ent")
pkgPath, err := PkgPath(e.Config, target)
require.NoError(t, err)
require.Equal(t, "golang.org/x/ent", pkgPath)
e.Config.Dir = filepath.Dir(e.File("golang.org/x", "y/y.go"))
target = filepath.Join(e.Config.Dir, "ent")
pkgPath, err = PkgPath(e.Config, target)
require.NoError(t, err)
require.Equal(t, "golang.org/x/y/ent", pkgPath)
target = filepath.Join(e.Config.Dir, "z/ent")
pkgPath, err = PkgPath(e.Config, target)
require.NoError(t, err)
require.Equal(t, "golang.org/x/y/z/ent", pkgPath)
target = filepath.Join(e.Config.Dir, "z/e/n/t")
pkgPath, err = PkgPath(e.Config, target)
require.Error(t, err)
require.Empty(t, pkgPath)
}

View File

@@ -0,0 +1,141 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package printer
import (
"fmt"
"io"
"reflect"
"strconv"
"strings"
"entgo.io/ent/entc/gen"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
)
// A Config controls the output of Fprint.
type Config struct {
io.Writer
}
// Print prints a table description of the graph to the given writer.
func (p Config) Print(g *gen.Graph) {
for _, n := range g.Nodes {
p.node(n)
}
}
// Fprint executes "pretty-printer" on the given writer.
func Fprint(w io.Writer, g *gen.Graph) {
Config{Writer: w}.Print(g)
}
// node returns description of a type. The format of the description is:
//
// Type:
// <Fields Table>
//
// <Edges Table>
func (p Config) node(t *gen.Type) {
var (
b strings.Builder
id []*gen.Field
table = tablewriter.NewWriter(&b)
header = []string{"Field", "Type", "Unique", "Optional", "Nillable", "Default", "UpdateDefault", "Immutable", "StructTag", "Validators", "Comment"}
)
b.WriteString(t.Name + ":\n")
table.Options(
tablewriter.WithHeaderConfig(tw.CellConfig{
Padding: tw.CellPadding{
Global: tw.Padding{
Left: tw.Space,
Right: tw.Space,
},
},
Formatting: tw.CellFormatting{
AutoFormat: tw.Off,
},
}),
tablewriter.WithRendition(tw.Rendition{
Symbols: tw.NewSymbols(tw.StyleASCII),
}),
)
table.Header(header)
var alignment = make([]tw.Align, 0)
if t.ID != nil {
id = append(id, t.ID)
}
for _, f := range append(id, t.Fields...) {
v := reflect.ValueOf(*f)
row := make([]string, len(header))
for i := 0; i < len(row)-1; i++ {
field := v.FieldByNameFunc(func(name string) bool {
// The first field is mapped from "Name" to "Field".
return name == "Name" && i == 0 || name == header[i]
})
row[i] = fmt.Sprint(field.Interface())
_, err := strconv.Atoi(row[i])
if err == nil {
alignment = append(alignment, tw.AlignRight)
} else {
alignment = append(alignment, tw.AlignLeft)
}
}
row[len(row)-1] = f.Comment()
_ = table.Append(row)
table.Options(
tablewriter.WithRowAlignmentConfig(
tw.CellAlignment{PerColumn: alignment},
),
)
}
err := table.Render()
if err != nil {
return
}
// Create new table for edges
table = tablewriter.NewWriter(&b)
table.Options(
tablewriter.WithHeaderConfig(tw.CellConfig{
Formatting: tw.CellFormatting{AutoFormat: tw.Off},
Padding: tw.CellPadding{
Global: tw.Padding{
Left: tw.Space,
Right: tw.Space,
},
},
}),
tablewriter.WithRendition(tw.Rendition{
Symbols: tw.NewSymbols(tw.StyleASCII),
}),
)
table.Header([]string{"Edge", "Type", "Inverse", "BackRef", "Relation", "Unique", "Optional", "Comment"})
hasEdges := false
for _, e := range t.Edges {
hasEdges = true
err := table.Append([]string{
e.Name,
e.Type.Name,
strconv.FormatBool(e.IsInverse()),
e.Inverse,
e.Rel.Type.String(),
strconv.FormatBool(e.Unique),
strconv.FormatBool(e.Optional),
e.Comment(),
})
if err != nil {
return
}
}
if hasEdges {
err := table.Render()
if err != nil {
return
}
}
io.WriteString(p, strings.ReplaceAll(b.String(), "\n", "\n\t")+"\n")
}

View File

@@ -0,0 +1,177 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package printer
import (
"strings"
"testing"
"entgo.io/ent/entc/gen"
"entgo.io/ent/schema/field"
"github.com/stretchr/testify/assert"
)
func TestPrinter_Print(t *testing.T) {
tests := []struct {
input *gen.Graph
out string
}{
{
input: &gen.Graph{
Nodes: []*gen.Type{
{
Name: "User",
ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}},
Fields: []*gen.Field{
{Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1},
{Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true},
{Name: "created_at", Type: &field.TypeInfo{Type: field.TypeTime}, Nillable: true, Immutable: true},
},
},
},
},
out: `
User:
+------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment |
+------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| id | int | false | false | false | false | false | false | | 0 | |
| name | string | false | false | false | false | false | false | | 1 | |
| age | int | false | false | true | false | false | false | | 0 | |
| created_at | time.Time | false | false | true | false | false | true | | 0 | |
+------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
`,
},
{
input: &gen.Graph{
Nodes: []*gen.Type{
{
Name: "User",
ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}},
Edges: []*gen.Edge{
{Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true},
{Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}},
},
},
},
},
out: `
User:
+-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment |
+-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| id | int | false | false | false | false | false | false | | 0 | |
+-------+------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
+--------+-------+---------+---------+----------+--------+----------+---------+
| Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment |
+--------+-------+---------+---------+----------+--------+----------+---------+
| groups | Group | false | | M2M | false | true | |
| spouse | User | false | | O2O | true | false | |
+--------+-------+---------+---------+----------+--------+----------+---------+
`,
},
{
input: &gen.Graph{
Nodes: []*gen.Type{
{
Name: "User",
ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}},
Fields: []*gen.Field{
{Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1},
{Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true},
},
Edges: []*gen.Edge{
{Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true},
{Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}},
},
},
},
},
out: `
User:
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| id | int | false | false | false | false | false | false | | 0 | |
| name | string | false | false | false | false | false | false | | 1 | |
| age | int | false | false | true | false | false | false | | 0 | |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
+--------+-------+---------+---------+----------+--------+----------+---------+
| Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment |
+--------+-------+---------+---------+----------+--------+----------+---------+
| groups | Group | false | | M2M | false | true | |
| spouse | User | false | | O2O | true | false | |
+--------+-------+---------+---------+----------+--------+----------+---------+
`,
},
{
input: &gen.Graph{
Nodes: []*gen.Type{
{
Name: "User",
ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}},
Fields: []*gen.Field{
{Name: "name", Type: &field.TypeInfo{Type: field.TypeString}, Validators: 1},
{Name: "age", Type: &field.TypeInfo{Type: field.TypeInt}, Nillable: true},
},
Edges: []*gen.Edge{
{Name: "groups", Type: &gen.Type{Name: "Group"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true},
{Name: "spouse", Type: &gen.Type{Name: "User"}, Unique: true, Rel: gen.Relation{Type: gen.O2O}},
},
},
{
Name: "Group",
ID: &gen.Field{Name: "id", Type: &field.TypeInfo{Type: field.TypeInt}},
Fields: []*gen.Field{
{Name: "name", Type: &field.TypeInfo{Type: field.TypeString}},
},
Edges: []*gen.Edge{
{Name: "users", Type: &gen.Type{Name: "User"}, Rel: gen.Relation{Type: gen.M2M}, Optional: true},
},
},
},
},
out: `
User:
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| id | int | false | false | false | false | false | false | | 0 | |
| name | string | false | false | false | false | false | false | | 1 | |
| age | int | false | false | true | false | false | false | | 0 | |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
+--------+-------+---------+---------+----------+--------+----------+---------+
| Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment |
+--------+-------+---------+---------+----------+--------+----------+---------+
| groups | Group | false | | M2M | false | true | |
| spouse | User | false | | O2O | true | false | |
+--------+-------+---------+---------+----------+--------+----------+---------+
Group:
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| Field | Type | Unique | Optional | Nillable | Default | UpdateDefault | Immutable | StructTag | Validators | Comment |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
| id | int | false | false | false | false | false | false | | 0 | |
| name | string | false | false | false | false | false | false | | 0 | |
+-------+--------+--------+----------+----------+---------+---------------+-----------+-----------+------------+---------+
+-------+------+---------+---------+----------+--------+----------+---------+
| Edge | Type | Inverse | BackRef | Relation | Unique | Optional | Comment |
+-------+------+---------+---------+----------+--------+----------+---------+
| users | User | false | | M2M | false | true | |
+-------+------+---------+---------+----------+--------+----------+---------+
`,
},
}
for _, tt := range tests {
b := &strings.Builder{}
Fprint(b, tt.input)
assert.Equal(t, tt.out, "\n"+b.String())
}
}

208
dialect/dialect.go Normal file
View File

@@ -0,0 +1,208 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package dialect
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"log"
"github.com/google/uuid"
)
// Dialect names for external usage.
const (
MySQL = "mysql"
SQLite = "sqlite3"
Postgres = "postgres"
Gremlin = "gremlin"
)
// ExecQuerier wraps the 2 database operations.
type ExecQuerier interface {
// Exec executes a query that does not return records. For example, in SQL, INSERT or UPDATE.
// It scans the result into the pointer v. For SQL drivers, it is dialect/sql.Result.
Exec(ctx context.Context, query string, args, v any) error
// Query executes a query that returns rows, typically a SELECT in SQL.
// It scans the result into the pointer v. For SQL drivers, it is *dialect/sql.Rows.
Query(ctx context.Context, query string, args, v any) error
}
// Driver is the interface that wraps all necessary operations for ent clients.
type Driver interface {
ExecQuerier
// Tx starts and returns a new transaction.
// The provided context is used until the transaction is committed or rolled back.
Tx(context.Context) (Tx, error)
// Close closes the underlying connection.
Close() error
// Dialect returns the dialect name of the driver.
Dialect() string
}
// Tx wraps the Exec and Query operations in transaction.
type Tx interface {
ExecQuerier
driver.Tx
}
type nopTx struct {
Driver
}
func (nopTx) Commit() error { return nil }
func (nopTx) Rollback() error { return nil }
// NopTx returns a Tx with a no-op Commit / Rollback methods wrapping
// the provided Driver d.
func NopTx(d Driver) Tx {
return nopTx{d}
}
// DebugDriver is a driver that logs all driver operations.
type DebugDriver struct {
Driver // underlying driver.
log func(context.Context, ...any) // log function. defaults to log.Println.
}
// Debug gets a driver and an optional logging function, and returns
// a new debugged-driver that prints all outgoing operations.
func Debug(d Driver, logger ...func(...any)) Driver {
logf := log.Println
if len(logger) == 1 {
logf = logger[0]
}
drv := &DebugDriver{d, func(_ context.Context, v ...any) { logf(v...) }}
return drv
}
// DebugWithContext gets a driver and a logging function, and returns
// a new debugged-driver that prints all outgoing operations with context.
func DebugWithContext(d Driver, logger func(context.Context, ...any)) Driver {
drv := &DebugDriver{d, logger}
return drv
}
// Exec logs its params and calls the underlying driver Exec method.
func (d *DebugDriver) Exec(ctx context.Context, query string, args, v any) error {
d.log(ctx, fmt.Sprintf("driver.Exec: query=%v args=%v", query, args))
return d.Driver.Exec(ctx, query, args, v)
}
// ExecContext logs its params and calls the underlying driver ExecContext method if it is supported.
func (d *DebugDriver) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
drv, ok := d.Driver.(interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
})
if !ok {
return nil, fmt.Errorf("Driver.ExecContext is not supported")
}
d.log(ctx, fmt.Sprintf("driver.ExecContext: query=%v args=%v", query, args))
return drv.ExecContext(ctx, query, args...)
}
// Query logs its params and calls the underlying driver Query method.
func (d *DebugDriver) Query(ctx context.Context, query string, args, v any) error {
d.log(ctx, fmt.Sprintf("driver.Query: query=%v args=%v", query, args))
return d.Driver.Query(ctx, query, args, v)
}
// QueryContext logs its params and calls the underlying driver QueryContext method if it is supported.
func (d *DebugDriver) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
drv, ok := d.Driver.(interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
})
if !ok {
return nil, fmt.Errorf("Driver.QueryContext is not supported")
}
d.log(ctx, fmt.Sprintf("driver.QueryContext: query=%v args=%v", query, args))
return drv.QueryContext(ctx, query, args...)
}
// Tx adds an log-id for the transaction and calls the underlying driver Tx command.
func (d *DebugDriver) Tx(ctx context.Context) (Tx, error) {
tx, err := d.Driver.Tx(ctx)
if err != nil {
return nil, err
}
id := uuid.New().String()
d.log(ctx, fmt.Sprintf("driver.Tx(%s): started", id))
return &DebugTx{tx, id, d.log, ctx}, nil
}
// BeginTx adds an log-id for the transaction and calls the underlying driver BeginTx command if it is supported.
func (d *DebugDriver) BeginTx(ctx context.Context, opts *sql.TxOptions) (Tx, error) {
drv, ok := d.Driver.(interface {
BeginTx(context.Context, *sql.TxOptions) (Tx, error)
})
if !ok {
return nil, fmt.Errorf("Driver.BeginTx is not supported")
}
tx, err := drv.BeginTx(ctx, opts)
if err != nil {
return nil, err
}
id := uuid.New().String()
d.log(ctx, fmt.Sprintf("driver.BeginTx(%s): started", id))
return &DebugTx{tx, id, d.log, ctx}, nil
}
// DebugTx is a transaction implementation that logs all transaction operations.
type DebugTx struct {
Tx // underlying transaction.
id string // transaction logging id.
log func(context.Context, ...any) // log function. defaults to fmt.Println.
ctx context.Context // underlying transaction context.
}
// Exec logs its params and calls the underlying transaction Exec method.
func (d *DebugTx) Exec(ctx context.Context, query string, args, v any) error {
d.log(ctx, fmt.Sprintf("Tx(%s).Exec: query=%v args=%v", d.id, query, args))
return d.Tx.Exec(ctx, query, args, v)
}
// ExecContext logs its params and calls the underlying transaction ExecContext method if it is supported.
func (d *DebugTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
drv, ok := d.Tx.(interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
})
if !ok {
return nil, fmt.Errorf("Tx.ExecContext is not supported")
}
d.log(ctx, fmt.Sprintf("Tx(%s).ExecContext: query=%v args=%v", d.id, query, args))
return drv.ExecContext(ctx, query, args...)
}
// Query logs its params and calls the underlying transaction Query method.
func (d *DebugTx) Query(ctx context.Context, query string, args, v any) error {
d.log(ctx, fmt.Sprintf("Tx(%s).Query: query=%v args=%v", d.id, query, args))
return d.Tx.Query(ctx, query, args, v)
}
// QueryContext logs its params and calls the underlying transaction QueryContext method if it is supported.
func (d *DebugTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
drv, ok := d.Tx.(interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
})
if !ok {
return nil, fmt.Errorf("Tx.QueryContext is not supported")
}
d.log(ctx, fmt.Sprintf("Tx(%s).QueryContext: query=%v args=%v", d.id, query, args))
return drv.QueryContext(ctx, query, args...)
}
// Commit logs this step and calls the underlying transaction Commit method.
func (d *DebugTx) Commit() error {
d.log(d.ctx, fmt.Sprintf("Tx(%s): committed", d.id))
return d.Tx.Commit()
}
// Rollback logs this step and calls the underlying transaction Rollback method.
func (d *DebugTx) Rollback() error {
d.log(d.ctx, fmt.Sprintf("Tx(%s): rollbacked", d.id))
return d.Tx.Rollback()
}

View File

@@ -0,0 +1,857 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package entsql
import (
"errors"
"fmt"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/schema"
)
// Annotation is a builtin schema annotation for attaching
// SQL metadata to schema objects for both codegen and runtime.
type Annotation struct {
// The Schema option allows setting the schema which the table belongs to.
// Note, this option is no-op for Ent default migration engine. However, schema
// extensions (like Atlas) can accept this option and implement it accordingly.
//
// entsql.Annotation{
// Schema: "public",
// }
//
Schema string `json:"schema,omitempty"`
// The Table option allows overriding the default table
// name that is generated by ent. For example:
//
// entsql.Annotation{
// Table: "Users",
// }
//
Table string `json:"table,omitempty"`
// Charset defines the character-set of the table. For example:
//
// entsql.Annotation{
// Charset: "utf8mb4",
// }
//
Charset string `json:"charset,omitempty"`
// Collation defines the collation of the table (a set of rules for comparing
// characters in a character set). For example:
//
// entsql.Annotation{
// Collation: "utf8mb4_bin",
// }
//
Collation string `json:"collation,omitempty"`
// Default specifies a literal default value of a column. Note that using
// this option overrides the default behavior of the code-generation.
//
// entsql.Annotation{
// Default: `{"key":"value"}`,
// }
//
Default string `json:"default,omitempty"`
// DefaultExpr specifies an expression default value of a column. Using this option,
// users can define custom expressions to be set as database default values. Note that
// using this option overrides the default behavior of the code-generation.
//
// entsql.Annotation{
// DefaultExpr: "CURRENT_TIMESTAMP",
// }
//
// entsql.Annotation{
// DefaultExpr: "uuid_generate_v4()",
// }
//
// entsql.Annotation{
// DefaultExpr: "(a + b)",
// }
//
DefaultExpr string `json:"default_expr,omitempty"`
// DefaultExpr specifies an expression default value of a column per dialect.
// See, DefaultExpr for full doc.
//
// entsql.Annotation{
// DefaultExprs: map[string]string{
// dialect.MySQL: "uuid()",
// dialect.Postgres: "uuid_generate_v4",
// }
//
DefaultExprs map[string]string `json:"default_exprs,omitempty"`
// Options defines the additional table options. For example:
//
// entsql.Annotation{
// Options: "ENGINE = INNODB",
// }
//
Options string `json:"options,omitempty"`
// Size defines the column size in the generated schema. For example:
//
// entsql.Annotation{
// Size: 128,
// }
//
Size int64 `json:"size,omitempty"`
// WithComments specifies whether fields' comments should
// be stored in the database schema as column comments.
//
// withCommentsEnabled := true
// entsql.WithComments{
// WithComments: &withCommentsEnabled,
// }
//
WithComments *bool `json:"with_comments,omitempty"`
// Incremental defines the auto-incremental behavior of a column. For example:
//
// incrementalEnabled := true
// entsql.Annotation{
// Incremental: &incrementalEnabled,
// }
//
// By default, this value is nil defaulting to whatever best fits each scenario.
//
Incremental *bool `json:"incremental,omitempty"`
// IncrementStart defines the auto-incremental start value of a column. For example:
//
// incrementStart := 100
// entsql.Annotation{
// IncrementStart: &incrementStart,
// }
//
// By default, this value is nil defaulting to whatever the database settings are.
//
IncrementStart *int `json:"increment_start,omitempty"`
// OnDelete specifies a custom referential action for DELETE operations on parent
// table that has matching rows in the child table.
//
// For example, in order to delete rows from the parent table and automatically delete
// their matching rows in the child table, pass the following annotation:
//
// entsql.Annotation{
// OnDelete: entsql.Cascade,
// }
//
OnDelete ReferenceOption `json:"on_delete,omitempty"`
// Check allows injecting custom "DDL" for setting an unnamed "CHECK" clause in "CREATE TABLE".
//
// entsql.Annotation{
// Check: "age < 10",
// }
//
Check string `json:"check,omitempty"`
// Checks allows injecting custom "DDL" for setting named "CHECK" clauses in "CREATE TABLE".
//
// entsql.Annotation{
// Checks: map[string]string{
// "valid_discount": "price > discount_price",
// },
// }
//
Checks map[string]string `json:"checks,omitempty"`
// Skip indicates that the field or the schema is skipped/ignored during
// migration (e.g., defined externally).
//
// entsql.Annotation{
// Skip: true,
// }
//
Skip bool `json:"skip,omitempty"`
// ViewAs allows defining a view for the schema. For example:
//
// entsql.Annotation{
// View: "SELECT name FROM users",
// }
ViewAs string `json:"view_as,omitempty"`
// ViewFor allows defining a view for the schema per dialect. For example:
//
// entsql.Annotation{
// ViewFor: map[string]string{
// dialect.MySQL: "...",
// dialect.Postgres: "...",
// },
// }
ViewFor map[string]string `json:"view_for,omitempty"`
// error occurs during annotation build. This field is not
// serialized to JSON and used only by the codegen loader.
err error
}
// Name describes the annotation name.
func (Annotation) Name() string {
return "EntSQL"
}
// The Schema option allows setting the schema which the table belongs to.
// Note, this option is no-op for Ent default migration engine. However, schema
// extensions (like Atlas) can accept this option and implement it accordingly.
//
// func (T) Annotations() []schema.Annotation {
// return []schema.Annotation{
// entsql.Schema("public"),
// }
// }
func Schema(s string) *Annotation {
return &Annotation{
Schema: s,
}
}
// The Table option allows overriding the default table
// name that is generated by ent. For example:
//
// func (T) Annotations() []schema.Annotation {
// return []schema.Annotation{
// entsql.Table("Users"),
// }
// }
func Table(t string) *Annotation {
return &Annotation{
Table: t,
}
}
// SchemaTable allows setting both schema and table name in one annotation.
func SchemaTable(s, t string) *Annotation {
return &Annotation{
Schema: s,
Table: t,
}
}
// Check allows injecting custom "DDL" for setting an unnamed "CHECK" clause in "CREATE TABLE".
//
// entsql.Annotation{
// Check: "(`age` < 10)",
// }
func Check(c string) *Annotation {
return &Annotation{
Check: c,
}
}
// Checks allows injecting custom "DDL" for setting named "CHECK" clauses in "CREATE TABLE".
//
// entsql.Annotation{
// Checks: map[string]string{
// "valid_discount": "price > discount_price",
// },
// }
func Checks(c map[string]string) *Annotation {
return &Annotation{
Checks: c,
}
}
// Skip indicates that the field or the schema is skipped/ignored during
// migration (e.g., defined externally).
func Skip() *Annotation {
return &Annotation{Skip: true}
}
// View specifies the definition of a view.
func View(as string) *Annotation {
return &Annotation{ViewAs: as}
}
// ViewFor specifies the definition of a view.
func ViewFor(dialect string, as func(*sql.Selector)) *Annotation {
b := sql.Dialect(dialect).Select()
as(b)
switch q, args := b.Query(); {
case len(args) > 0:
return &Annotation{
err: fmt.Errorf("entsql: view query should not contain arguments. got: %d", len(args)),
}
case q == "":
return &Annotation{
err: errors.New("entsql: view query is empty"),
}
case b.Err() != nil:
return &Annotation{
err: b.Err(),
}
default:
return &Annotation{
ViewFor: map[string]string{dialect: q},
}
}
}
// Default specifies a literal default value of a column. Note that using
// this option overrides the default behavior of the code-generation.
//
// entsql.Annotation{
// Default: `{"key":"value"}`,
// }
func Default(literal string) *Annotation {
return &Annotation{
Default: literal,
}
}
// DefaultExpr specifies an expression default value for the annotated column.
// Using this option, users can define custom expressions to be set as database
// default values.Note that using this option overrides the default behavior of
// the code-generation.
//
// field.UUID("id", uuid.Nil).
// Default(uuid.New).
// Annotations(
// entsql.DefaultExpr("uuid_generate_v4()"),
// )
func DefaultExpr(expr string) *Annotation {
return &Annotation{
DefaultExpr: expr,
}
}
// DefaultExprs specifies an expression default value for the annotated
// column per dialect. See, DefaultExpr for full doc.
//
// field.UUID("id", uuid.Nil).
// Default(uuid.New).
// Annotations(
// entsql.DefaultExprs(map[string]string{
// dialect.MySQL: "uuid()",
// dialect.Postgres: "uuid_generate_v4()",
// }),
// )
func DefaultExprs(exprs map[string]string) *Annotation {
return &Annotation{
DefaultExprs: exprs,
}
}
// WithComments specifies whether fields' comments should
// be stored in the database schema as column comments.
//
// func (T) Annotations() []schema.Annotation {
// return []schema.Annotation{
// entsql.WithComments(true),
// }
// }
func WithComments(b bool) *Annotation {
return &Annotation{
WithComments: &b,
}
}
// OnDelete specifies a custom referential action for DELETE operations on parent
// table that has matching rows in the child table.
//
// For example, in order to delete rows from the parent table and automatically delete
// their matching rows in the child table, pass the following annotation:
//
// func (T) Annotations() []schema.Annotation {
// return []schema.Annotation{
// entsql.OnDelete(entsql.Cascade),
// }
// }
func OnDelete(opt ReferenceOption) *Annotation {
return &Annotation{
OnDelete: opt,
}
}
// IncrementStart specifies the starting value for auto-increment columns.
//
// For example, in order to define the starting value for auto-increment to be 100:
//
// func (T) Annotations() []schema.Annotation {
// return []schema.Annotation{
// entsql.IncrementStart(100),
// }
// }
func IncrementStart(i int) *Annotation {
return &Annotation{
IncrementStart: &i,
}
}
// Merge implements the schema.Merger interface.
func (a Annotation) Merge(other schema.Annotation) schema.Annotation {
var ant Annotation
switch other := other.(type) {
case Annotation:
ant = other
case *Annotation:
if other != nil {
ant = *other
}
default:
return a
}
if s := ant.Schema; s != "" {
a.Schema = s
}
if t := ant.Table; t != "" {
a.Table = t
}
if c := ant.Charset; c != "" {
a.Charset = c
}
if c := ant.Collation; c != "" {
a.Collation = c
}
if d := ant.Default; d != "" {
a.Default = d
}
if d := ant.DefaultExpr; d != "" {
a.DefaultExpr = d
}
if d := ant.DefaultExprs; d != nil {
if a.DefaultExprs == nil {
a.DefaultExprs = make(map[string]string)
}
for dialect, x := range d {
a.DefaultExprs[dialect] = x
}
}
if o := ant.Options; o != "" {
a.Options = o
}
if s := ant.Size; s != 0 {
a.Size = s
}
if b := ant.WithComments; b != nil {
a.WithComments = b
}
if i := ant.Incremental; i != nil {
a.Incremental = i
}
if i := ant.IncrementStart; i != nil {
a.IncrementStart = i
}
if od := ant.OnDelete; od != "" {
a.OnDelete = od
}
if c := ant.Check; c != "" {
a.Check = c
}
if checks := ant.Checks; len(checks) > 0 {
if a.Checks == nil {
a.Checks = make(map[string]string)
}
for name, check := range checks {
a.Checks[name] = check
}
}
if ant.Skip {
a.Skip = true
}
if v := ant.ViewAs; v != "" {
a.ViewAs = v
}
if vf := ant.ViewFor; len(vf) > 0 {
if a.ViewFor == nil {
a.ViewFor = make(map[string]string)
}
for dialect, view := range vf {
a.ViewFor[dialect] = view
}
}
if ant.err != nil {
a.err = errors.Join(a.err, ant.err)
}
return a
}
// Err returns the error that occurred during annotation build, if any.
func (a Annotation) Err() error {
return a.err
}
var _ interface {
schema.Annotation
schema.Merger
} = (*Annotation)(nil)
// ReferenceOption for constraint actions.
type ReferenceOption string
// Reference options (actions) specified by ON UPDATE and ON DELETE
// subclauses of the FOREIGN KEY clause.
const (
NoAction ReferenceOption = "NO ACTION"
Restrict ReferenceOption = "RESTRICT"
Cascade ReferenceOption = "CASCADE"
SetNull ReferenceOption = "SET NULL"
SetDefault ReferenceOption = "SET DEFAULT"
)
// IndexAnnotation is a builtin schema annotation for attaching
// SQL metadata to schema indexes for both codegen and runtime.
type IndexAnnotation struct {
// Prefix defines a column prefix for a single string column index.
// In MySQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(entsql.Prefix(100))
//
// CREATE INDEX `table_column` ON `table`(`column`(100))
//
Prefix uint
// PrefixColumns defines column prefixes for a multi-column index.
// In MySQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.PrefixColumn("c1", 100),
// entsql.PrefixColumn("c2", 200),
// )
//
// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1`(100), `c2`(200), `c3`)
//
PrefixColumns map[string]uint
// Desc defines the DESC clause for a single column index.
// In MySQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(entsql.Desc())
//
// CREATE INDEX `table_column` ON `table`(`column` DESC)
//
Desc bool
// DescColumns defines the DESC clause for columns in multi-column index.
// In MySQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.DescColumns("c1", "c2"),
// )
//
// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1` DESC, `c2` DESC, `c3`)
//
DescColumns map[string]bool
// IncludeColumns defines the INCLUDE clause for the index.
// Works only in Postgres and its definition is as follows:
//
// index.Fields("c1").
// Annotation(
// entsql.IncludeColumns("c2"),
// )
//
// CREATE INDEX "table_column" ON "table"("c1") INCLUDE ("c2")
//
IncludeColumns []string
// Type defines the type of the index.
// In MySQL, the following annotation maps to:
//
// index.Fields("c1").
// Annotation(
// entsql.IndexType("FULLTEXT"),
// )
//
// CREATE FULLTEXT INDEX `table_c1` ON `table`(`c1`)
//
Type string
// Types is like the Type option but allows mapping an index-type per dialect.
//
// index.Fields("c1").
// Annotation(
// entsql.IndexTypes(map[string]string{
// dialect.MySQL: "FULLTEXT",
// dialect.Postgres: "GIN",
// }),
// )
//
Types map[string]string
// OpClass defines the operator class for a single string column index.
// In PostgreSQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(
// entsql.IndexType("BRIN"),
// entsql.OpClass("int8_bloom_ops"),
// )
//
// CREATE INDEX "table_column" ON "table" USING BRIN ("column" int8_bloom_ops)
//
OpClass string
// OpClassColumns defines operator-classes for a multi-column index.
// In PostgreSQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.IndexType("BRIN"),
// entsql.OpClassColumn("c1", "int8_bloom_ops"),
// entsql.OpClassColumn("c2", "int8_minmax_multi_ops(values_per_range=8)"),
// )
//
// CREATE INDEX "table_column" ON "table" USING BRIN ("c1" int8_bloom_ops, "c2" int8_minmax_multi_ops(values_per_range=8), "c3")
//
OpClassColumns map[string]string
// IndexWhere allows configuring partial indexes in SQLite and PostgreSQL.
// Read more: https://postgresql.org/docs/current/indexes-partial.html.
//
// Note that the `WHERE` clause should be defined exactly like it is
// stored in the database (i.e. normal form). Read more about this on
// the Atlas website: https://atlasgo.io/concepts/dev-database#diffing.
//
// index.Fields("a").
// Annotations(
// entsql.IndexWhere("b AND c > 0"),
// )
// CREATE INDEX "table_a" ON "table"("a") WHERE (b AND c > 0)
Where string
}
// Prefix returns a new index annotation with a single string column index.
// In MySQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(entsql.Prefix(100))
//
// CREATE INDEX `table_column` ON `table`(`column`(100))
func Prefix(prefix uint) *IndexAnnotation {
return &IndexAnnotation{
Prefix: prefix,
}
}
// PrefixColumn returns a new index annotation with column prefix for
// multi-column indexes. In MySQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.PrefixColumn("c1", 100),
// entsql.PrefixColumn("c2", 200),
// )
//
// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1`(100), `c2`(200), `c3`)
func PrefixColumn(name string, prefix uint) *IndexAnnotation {
return &IndexAnnotation{
PrefixColumns: map[string]uint{
name: prefix,
},
}
}
// OpClass defines the operator class for a single string column index.
// In PostgreSQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(
// entsql.IndexType("BRIN"),
// entsql.OpClass("int8_bloom_ops"),
// )
//
// CREATE INDEX "table_column" ON "table" USING BRIN ("column" int8_bloom_ops)
func OpClass(op string) *IndexAnnotation {
return &IndexAnnotation{
OpClass: op,
}
}
// OpClassColumn returns a new index annotation with column operator
// class for multi-column indexes. In PostgreSQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.IndexType("BRIN"),
// entsql.OpClassColumn("c1", "int8_bloom_ops"),
// entsql.OpClassColumn("c2", "int8_minmax_multi_ops(values_per_range=8)"),
// )
//
// CREATE INDEX "table_column" ON "table" USING BRIN ("c1" int8_bloom_ops, "c2" int8_minmax_multi_ops(values_per_range=8), "c3")
func OpClassColumn(name, op string) *IndexAnnotation {
return &IndexAnnotation{
OpClassColumns: map[string]string{
name: op,
},
}
}
// Desc returns a new index annotation with the DESC clause for a
// single column index. In MySQL, the following annotation maps to:
//
// index.Fields("column").
// Annotation(entsql.Desc())
//
// CREATE INDEX `table_column` ON `table`(`column` DESC)
func Desc() *IndexAnnotation {
return &IndexAnnotation{
Desc: true,
}
}
// DescColumns returns a new index annotation with the DESC clause attached to
// the columns in the index. In MySQL, the following annotation maps to:
//
// index.Fields("c1", "c2", "c3").
// Annotation(
// entsql.DescColumns("c1", "c2"),
// )
//
// CREATE INDEX `table_c1_c2_c3` ON `table`(`c1` DESC, `c2` DESC, `c3`)
func DescColumns(names ...string) *IndexAnnotation {
ant := &IndexAnnotation{
DescColumns: make(map[string]bool, len(names)),
}
for i := range names {
ant.DescColumns[names[i]] = true
}
return ant
}
// IncludeColumns defines the INCLUDE clause for the index.
// Works only in Postgres and its definition is as follows:
//
// index.Fields("c1").
// Annotation(
// entsql.IncludeColumns("c2"),
// )
//
// CREATE INDEX "table_column" ON "table"("c1") INCLUDE ("c2")
func IncludeColumns(names ...string) *IndexAnnotation {
return &IndexAnnotation{IncludeColumns: names}
}
// IndexType defines the type of the index.
// In MySQL, the following annotation maps to:
//
// index.Fields("c1").
// Annotation(
// entsql.IndexType("FULLTEXT"),
// )
//
// CREATE FULLTEXT INDEX `table_c1` ON `table`(`c1`)
func IndexType(t string) *IndexAnnotation {
return &IndexAnnotation{Type: t}
}
// IndexTypes is like the Type option but allows mapping an index-type per dialect.
//
// index.Fields("c1").
// Annotations(
// entsql.IndexTypes(map[string]string{
// dialect.MySQL: "FULLTEXT",
// dialect.Postgres: "GIN",
// }),
// )
func IndexTypes(types map[string]string) *IndexAnnotation {
return &IndexAnnotation{Types: types}
}
// IndexWhere allows configuring partial indexes in SQLite and PostgreSQL.
// Read more: https://postgresql.org/docs/current/indexes-partial.html.
//
// Note that the `WHERE` clause should be defined exactly like it is
// stored in the database (i.e. normal form). Read more about this on the
// Atlas website: https://atlasgo.io/concepts/dev-database#diffing.
//
// index.Fields("a").
// Annotations(
// entsql.IndexWhere("b AND c > 0"),
// )
// CREATE INDEX "table_a" ON "table"("a") WHERE (b AND c > 0)
func IndexWhere(pred string) *IndexAnnotation {
return &IndexAnnotation{Where: pred}
}
// Name describes the annotation name.
func (IndexAnnotation) Name() string {
return "EntSQLIndexes"
}
// Merge implements the schema.Merger interface.
func (a IndexAnnotation) Merge(other schema.Annotation) schema.Annotation {
var ant IndexAnnotation
switch other := other.(type) {
case IndexAnnotation:
ant = other
case *IndexAnnotation:
if other != nil {
ant = *other
}
default:
return a
}
if ant.Prefix != 0 {
a.Prefix = ant.Prefix
}
if ant.PrefixColumns != nil {
if a.PrefixColumns == nil {
a.PrefixColumns = make(map[string]uint)
}
for column, prefix := range ant.PrefixColumns {
a.PrefixColumns[column] = prefix
}
}
if ant.OpClass != "" {
a.OpClass = ant.OpClass
}
if ant.OpClassColumns != nil {
if a.OpClassColumns == nil {
a.OpClassColumns = make(map[string]string)
}
for column, op := range ant.OpClassColumns {
a.OpClassColumns[column] = op
}
}
if ant.Desc {
a.Desc = ant.Desc
}
if ant.DescColumns != nil {
if a.DescColumns == nil {
a.DescColumns = make(map[string]bool)
}
for column, desc := range ant.DescColumns {
a.DescColumns[column] = desc
}
}
if ant.IncludeColumns != nil {
a.IncludeColumns = append(a.IncludeColumns, ant.IncludeColumns...)
}
if ant.Type != "" {
a.Type = ant.Type
}
if ant.Types != nil {
if a.Types == nil {
a.Types = make(map[string]string)
}
for dialect, t := range ant.Types {
a.Types[dialect] = t
}
}
if ant.Where != "" {
a.Where = ant.Where
}
return a
}
var _ interface {
schema.Annotation
schema.Merger
} = (*IndexAnnotation)(nil)

77
dialect/gremlin/client.go Normal file
View File

@@ -0,0 +1,77 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"fmt"
"net/http"
)
// RoundTripper is an interface representing the ability to execute a
// single gremlin transaction, obtaining the Response for a given Request.
type RoundTripper interface {
RoundTrip(context.Context, *Request) (*Response, error)
}
// The RoundTripperFunc type is an adapter to allow the use of ordinary functions as Gremlin RoundTripper.
type RoundTripperFunc func(context.Context, *Request) (*Response, error)
// RoundTrip calls f(ctx, r).
func (f RoundTripperFunc) RoundTrip(ctx context.Context, r *Request) (*Response, error) {
return f(ctx, r)
}
// Interceptor provides a hook to intercept the execution of a Gremlin Request.
type Interceptor func(RoundTripper) RoundTripper
// A Client is a gremlin client.
type Client struct {
// Transport specifies the mechanism by which individual
// Gremlin requests are made.
Transport RoundTripper
}
// MaxResponseSize defines the maximum response size allowed.
const MaxResponseSize = 2 << 20
// NewClient creates a gremlin client from config and options.
func NewClient(cfg Config, opt ...Option) (*Client, error) {
return cfg.Build(opt...)
}
// NewHTTPClient creates an http based gremlin client.
func NewHTTPClient(url string, client *http.Client) (*Client, error) {
transport, err := NewHTTPTransport(url, client)
if err != nil {
return nil, err
}
return &Client{transport}, nil
}
// Do sends a gremlin request and returns a gremlin response.
func (c Client) Do(ctx context.Context, req *Request) (*Response, error) {
rsp, err := c.Transport.RoundTrip(ctx, req)
if err == nil {
err = rsp.Err()
}
// If we got an error, and the context has been canceled,
// the context's error is probably more useful.
if err != nil && ctx.Err() != nil {
err = ctx.Err()
}
return rsp, err
}
// Query issues an eval request via the Do function.
func (c Client) Query(ctx context.Context, query string) (*Response, error) {
return c.Do(ctx, NewEvalRequest(query))
}
// Queryf formats a query string and invokes Query.
func (c Client) Queryf(ctx context.Context, format string, args ...any) (*Response, error) {
return c.Query(ctx, fmt.Sprintf(format, args...))
}

View File

@@ -0,0 +1,93 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"io"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestNewClient(t *testing.T) {
var cfg Config
cfg.Endpoint.URL, _ = url.Parse("http://gremlin-server/gremlin")
c, err := NewClient(cfg)
assert.NotNil(t, c)
assert.NoError(t, err)
}
type mockRoundTripper struct{ mock.Mock }
func (m *mockRoundTripper) RoundTrip(ctx context.Context, req *Request) (*Response, error) {
args := m.Called(ctx, req)
return args.Get(0).(*Response), args.Error(1)
}
func TestClientRequest(t *testing.T) {
ctx := context.Background()
req, rsp := &Request{}, &Response{}
var m mockRoundTripper
m.On("RoundTrip", ctx, req).
Run(func(mock.Arguments) { rsp.Status.Code = StatusSuccess }).
Return(rsp, nil).
Once()
defer m.AssertExpectations(t)
response, err := Client{&m}.Do(context.Background(), req)
assert.NoError(t, err)
assert.Equal(t, rsp, response)
}
func TestClientResponseError(t *testing.T) {
rsp := &Response{}
var m mockRoundTripper
m.On("RoundTrip", mock.Anything, mock.Anything).
Run(func(mock.Arguments) { rsp.Status.Code = StatusServerError }).
Return(rsp, nil).
Once()
defer m.AssertExpectations(t)
_, err := Client{&m}.Do(context.Background(), nil)
assert.Error(t, err)
}
func TestClientCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var m mockRoundTripper
m.On("RoundTrip", ctx, mock.Anything).
Run(func(mock.Arguments) { cancel() }).
Return(&Response{}, io.ErrUnexpectedEOF).
Once()
defer m.AssertExpectations(t)
_, err := Client{&m}.Query(ctx, "g.E()")
assert.EqualError(t, err, context.Canceled.Error())
}
func TestClientQuery(t *testing.T) {
rsp := &Response{}
rsp.Status.Code = StatusNoContent
var m mockRoundTripper
m.On("RoundTrip", mock.Anything, mock.Anything).
Run(func(args mock.Arguments) {
req := args.Get(1).(*Request)
assert.Equal(t, "g.V(1)", req.Arguments[ArgsGremlin])
}).
Return(rsp, nil).
Once()
defer m.AssertExpectations(t)
rsp, err := Client{&m}.Queryf(context.Background(), "g.V(%d)", 1)
assert.NotNil(t, rsp)
assert.NoError(t, err)
}

82
dialect/gremlin/config.go Normal file
View File

@@ -0,0 +1,82 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"fmt"
"net/http"
"net/url"
)
type (
// Config offers a declarative way to construct a client.
Config struct {
Endpoint Endpoint `env:"ENDPOINT" long:"endpoint" default:"" description:"gremlin endpoint to connect to"`
DisableExpansion bool `env:"DISABLE_EXPANSION" long:"disable-expansion" description:"disable bindings expansion"`
}
// An Option configured client.
Option func(*options)
options struct {
interceptors []Interceptor
httpClient *http.Client
}
// Endpoint wraps a url to add flag unmarshalling.
Endpoint struct {
*url.URL
}
)
// WithInterceptor adds interceptors to the client's transport.
func WithInterceptor(interceptors ...Interceptor) Option {
return func(opts *options) {
opts.interceptors = append(opts.interceptors, interceptors...)
}
}
// WithHTTPClient assigns underlying http client to be used by http transport.
func WithHTTPClient(client *http.Client) Option {
return func(opts *options) {
opts.httpClient = client
}
}
// Build constructs a client from Config.
func (cfg Config) Build(opt ...Option) (c *Client, err error) {
opts := cfg.buildOptions(opt)
switch cfg.Endpoint.Scheme {
case "http", "https":
c, err = NewHTTPClient(cfg.Endpoint.String(), opts.httpClient)
default:
err = fmt.Errorf("unsupported endpoint scheme: %s", cfg.Endpoint.Scheme)
}
if err != nil {
return nil, err
}
for i := len(opts.interceptors) - 1; i >= 0; i-- {
c.Transport = opts.interceptors[i](c.Transport)
}
if !cfg.DisableExpansion {
c.Transport = ExpandBindings(c.Transport)
}
return c, nil
}
func (Config) buildOptions(opts []Option) options {
var o options
for _, opt := range opts {
opt(&o)
}
return o
}
// UnmarshalFlag implements flag.Unmarshaler interface.
func (ep *Endpoint) UnmarshalFlag(value string) (err error) {
ep.URL, err = url.Parse(value)
return
}

View File

@@ -0,0 +1,154 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"errors"
"net/http"
"net/url"
"testing"
"github.com/jessevdk/go-flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestConfigParsing(t *testing.T) {
var cfg Config
_, err := flags.ParseArgs(&cfg, []string{
"--disable-expansion",
"--endpoint", "http://localhost:8182/gremlin",
})
assert.NoError(t, err)
assert.True(t, cfg.DisableExpansion)
assert.Equal(t, "http", cfg.Endpoint.Scheme)
assert.Equal(t, "http://localhost:8182/gremlin", cfg.Endpoint.String())
cfg = Config{}
_, err = flags.ParseArgs(&cfg, nil)
assert.NoError(t, err)
assert.NotNil(t, cfg.Endpoint.URL)
}
func TestConfigBuild(t *testing.T) {
tests := []struct {
name string
cfg Config
opts []Option
wantErr bool
}{
{
name: "HTTP",
cfg: Config{
Endpoint: Endpoint{
URL: func() *url.URL {
u, _ := url.Parse("http://gremlin-server/gremlin")
return u
}(),
},
},
},
{
name: "NoScheme",
cfg: Config{
Endpoint: Endpoint{
URL: &url.URL{},
},
},
wantErr: true,
},
{
name: "BadScheme",
cfg: Config{
Endpoint: Endpoint{
URL: &url.URL{
Scheme: "bad",
},
},
},
wantErr: true,
},
{
name: "WithOptions",
cfg: Config{
Endpoint: Endpoint{
URL: func() *url.URL {
u, _ := url.Parse("http://gremlin-server/gremlin")
return u
}(),
},
DisableExpansion: true,
},
opts: []Option{WithHTTPClient(&http.Client{})},
},
{
name: "NoExpansion",
cfg: Config{
Endpoint: Endpoint{
URL: &url.URL{
Scheme: "bad",
},
},
DisableExpansion: true,
},
wantErr: true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
client, err := tc.cfg.Build(tc.opts...)
if !tc.wantErr {
assert.NotNil(t, client)
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
type testRoundTripper struct{ mock.Mock }
func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
args := rt.Called(req)
rsp, _ := args.Get(0).(*http.Response)
return rsp, args.Error(1)
}
func TestBuildWithHTTPClient(t *testing.T) {
var transport testRoundTripper
transport.On("RoundTrip", mock.Anything).
Return(nil, errors.New("noop")).
Once()
defer transport.AssertExpectations(t)
u, err := url.Parse("http://gremlin-server:8182/gremlin")
require.NoError(t, err)
client, err := Config{Endpoint: Endpoint{u}}.
Build(WithHTTPClient(&http.Client{Transport: &transport}))
require.NoError(t, err)
_, _ = client.Do(context.Background(), NewEvalRequest("g.V()"))
}
func TestExpandOrdering(t *testing.T) {
var cfg Config
cfg.Endpoint.URL, _ = url.Parse("http://gremlin-server/gremlin")
interceptor := func(RoundTripper) RoundTripper {
return RoundTripperFunc(func(ctx context.Context, req *Request) (*Response, error) {
assert.Equal(t, `g.V().hasLabel("user")`, req.Arguments[ArgsGremlin])
assert.Nil(t, req.Arguments[ArgsBindings])
return nil, errors.New("noop")
})
}
c, err := cfg.Build(WithInterceptor(interceptor))
require.NoError(t, err)
req := NewEvalRequest("g.V().hasLabel($1)", WithBindings(map[string]any{"$1": "user"}))
_, _ = c.Do(context.Background(), req)
}

58
dialect/gremlin/driver.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"fmt"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/gremlin/graph/dsl"
)
// Driver is a dialect.Driver implementation for TinkerPop gremlin.
type Driver struct {
*Client
}
// NewDriver returns a new dialect.Driver implementation for gremlin.
func NewDriver(c *Client) *Driver {
c.Transport = ExpandBindings(c.Transport)
return &Driver{c}
}
// Dialect implements the dialect.Dialect method.
func (Driver) Dialect() string { return dialect.Gremlin }
// Exec implements the dialect.Exec method.
func (c *Driver) Exec(ctx context.Context, query string, args, v any) error {
vr, ok := v.(*Response)
if !ok {
return fmt.Errorf("dialect/gremlin: invalid type %T. expect *gremlin.Response", v)
}
bindings, ok := args.(dsl.Bindings)
if !ok {
return fmt.Errorf("dialect/gremlin: invalid type %T. expect map[string]any for bindings", args)
}
res, err := c.Do(ctx, NewEvalRequest(query, WithBindings(bindings)))
if err != nil {
return err
}
*vr = *res
return nil
}
// Query implements the dialect.Query method.
func (c *Driver) Query(ctx context.Context, query string, args, v any) error {
return c.Exec(ctx, query, args, v)
}
// Close is a nop close call. It should close the connection in case of WS client.
func (Driver) Close() error { return nil }
// Tx returns a nop transaction.
func (c *Driver) Tx(context.Context) (dialect.Tx, error) { return dialect.NopTx(c), nil }
var _ dialect.Driver = (*Driver)(nil)

View File

@@ -0,0 +1,99 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
jsoniter "github.com/json-iterator/go"
)
type book struct {
ID string `json:"id" graphson:"g:UUID"`
Title string `json:"title"`
Author string `json:"author"`
Pages int `json:"num_pages"`
Chapters []string `json:"chapters"`
}
func generateObject() *book {
return &book{
ID: "21d5dcbf-1fd4-493e-9b74-d6c429f9e4a5",
Title: "The Art of Computer Programming, Vol. 2",
Author: "Donald E. Knuth",
Pages: 784,
Chapters: []string{"Random numbers", "Arithmetic"},
}
}
func BenchmarkMarshalObject(b *testing.B) {
obj := generateObject()
b.ReportAllocs()
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err := Marshal(obj)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkUnmarshalObject(b *testing.B) {
b.ReportAllocs()
out, err := Marshal(generateObject())
if err != nil {
b.Fatal(err)
}
obj := &book{}
b.ResetTimer()
for n := 0; n < b.N; n++ {
err = Unmarshal(out, obj)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkMarshalInterface(b *testing.B) {
b.ReportAllocs()
data, err := jsoniter.Marshal(generateObject())
if err != nil {
b.Fatal(err)
}
var obj any
if err = jsoniter.Unmarshal(data, &obj); err != nil {
b.Fatal(err)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
_, err = Marshal(obj)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkUnmarshalInterface(b *testing.B) {
b.ReportAllocs()
data, err := Marshal(generateObject())
if err != nil {
b.Fatal(err)
}
var obj any
b.ResetTimer()
for n := 0; n < b.N; n++ {
err = Unmarshal(data, &obj)
if err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,54 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/mock"
)
type mocker struct {
mock.Mock
}
// Encode belongs to jsoniter.ValEncoder interface.
func (m *mocker) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
m.Called(ptr, stream)
}
// IsEmpty belongs to jsoniter.ValEncoder interface.
func (m *mocker) IsEmpty(ptr unsafe.Pointer) bool {
args := m.Called(ptr)
return args.Bool(0)
}
// Decode implements jsoniter.ValDecoder interface.
func (m *mocker) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
m.Called(ptr, iter)
}
// CheckType implements typeChecker interface.
func (m *mocker) CheckType(typ Type) error {
args := m.Called(typ)
return args.Error(0)
}
// MarshalGraphson implements Marshaler interface.
func (m *mocker) MarshalGraphson() ([]byte, error) {
args := m.Called()
data, err := args.Get(0), args.Error(1)
if data == nil {
return nil, err
}
return data.([]byte), err
}
// UnmarshalGraphson implements Unmarshaler interface.
func (m *mocker) UnmarshalGraphson(data []byte) error {
args := m.Called(data)
return args.Error(0)
}

View File

@@ -0,0 +1,109 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"io"
"reflect"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
type decodeExtension struct {
jsoniter.DummyExtension
}
// Unmarshal parses the graphson encoded data and stores the result
// in the value pointed to by v.
func Unmarshal(data []byte, v any) error {
return config.Unmarshal(data, v)
}
// UnmarshalFromString parses the graphson encoded str and stores the result
// in the value pointed to by v.
func UnmarshalFromString(str string, v any) error {
return config.UnmarshalFromString(str, v)
}
// Decoder defines a graphson decoder.
type Decoder interface {
Decode(any) error
}
// NewDecoder create a graphson decoder.
func NewDecoder(r io.Reader) Decoder {
return config.NewDecoder(r)
}
// Unmarshaler is the interface implemented by types
// that can unmarshal a graphson description of themselves.
type Unmarshaler interface {
UnmarshalGraphson([]byte) error
}
// UpdateStructDescriptor decorates struct field encoders for graphson tags.
func (ext decodeExtension) UpdateStructDescriptor(desc *jsoniter.StructDescriptor) {
for _, binding := range desc.Fields {
if tag, ok := binding.Field.Tag().Lookup("graphson"); ok && tag != "-" {
if dec := ext.DecoratorOfStructField(binding.Decoder, tag); dec != nil {
binding.Decoder = dec
}
}
}
}
// CreateDecoder returns a value decoder for type.
func (ext decodeExtension) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder {
if dec := ext.DecoderOfRegistered(typ); dec != nil {
return dec
}
if dec := ext.DecoderOfUnmarshaler(typ); dec != nil {
return dec
}
if dec := ext.DecoderOfNative(typ); dec != nil {
return dec
}
switch typ.Kind() {
case reflect.Array:
return ext.DecoderOfArray(typ)
case reflect.Slice:
return ext.DecoderOfSlice(typ)
case reflect.Map:
return ext.DecoderOfMap(typ)
default:
return nil
}
}
// DecorateDecoder decorates an passed in value decoder for type.
func (ext decodeExtension) DecorateDecoder(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
if dec := ext.DecoratorOfRegistered(dec); dec != nil {
return dec
}
if dec := ext.DecoratorOfUnmarshaler(typ, dec); dec != nil {
return dec
}
if dec := ext.DecoratorOfTyper(typ, dec); dec != nil {
return dec
}
if dec := ext.DecoratorOfNative(typ, dec); dec != nil {
return dec
}
switch typ.Kind() {
case reflect.Ptr, reflect.Struct:
return dec
case reflect.Interface:
return ext.DecoratorOfInterface(typ, dec)
case reflect.Slice:
return ext.DecoratorOfSlice(typ, dec)
case reflect.Array:
return ext.DecoratorOfArray(dec)
case reflect.Map:
return ext.DecoratorOfMap(dec)
default:
return ext.DecoderOfError("graphson: unsupported type: %s", typ.String())
}
}

View File

@@ -0,0 +1,5 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson

View File

@@ -0,0 +1,98 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"io"
"reflect"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
type encodeExtension struct {
jsoniter.DummyExtension
}
// Marshal returns the graphson encoding of v.
func Marshal(v any) ([]byte, error) {
return config.Marshal(v)
}
// MarshalToString returns the graphson encoding of v as string.
func MarshalToString(v any) (string, error) {
return config.MarshalToString(v)
}
// Encoder defines a graphson encoder.
type Encoder interface {
Encode(any) error
}
// NewEncoder create a graphson encoder.
func NewEncoder(w io.Writer) Encoder {
return config.NewEncoder(w)
}
// Marshaler is the interface implemented by types that
// can marshal themselves as graphson.
type Marshaler interface {
MarshalGraphson() ([]byte, error)
}
// UpdateStructDescriptor decorates struct field encoders for graphson tags.
func (ext encodeExtension) UpdateStructDescriptor(desc *jsoniter.StructDescriptor) {
for _, binding := range desc.Fields {
if tag, ok := binding.Field.Tag().Lookup("graphson"); ok && tag != "-" {
if enc := ext.DecoratorOfStructField(binding.Encoder, tag); enc != nil {
binding.Encoder = enc
}
}
}
}
// CreateEncoder returns a value encoder for type.
func (ext encodeExtension) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder {
if enc := ext.EncoderOfRegistered(typ); enc != nil {
return enc
}
if enc := ext.EncoderOfNative(typ); enc != nil {
return enc
}
switch typ.Kind() {
case reflect.Map:
return ext.EncoderOfMap(typ)
default:
return nil
}
}
// DecorateEncoder decorates an passed in value encoder for type.
func (ext encodeExtension) DecorateEncoder(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
if enc := ext.DecoratorOfRegistered(enc); enc != nil {
return enc
}
if enc := ext.DecoratorOfMarshaler(typ, enc); enc != nil {
return enc
}
if enc := ext.DecoratorOfTyper(typ, enc); enc != nil {
return enc
}
if enc := ext.DecoratorOfNative(typ, enc); enc != nil {
return enc
}
switch typ.Kind() {
case reflect.Ptr, reflect.Interface, reflect.Struct:
return enc
case reflect.Array:
return ext.DecoratorOfArray(enc)
case reflect.Slice:
return ext.DecoratorOfSlice(typ, enc)
case reflect.Map:
return ext.DecoratorOfMap(enc)
default:
return ext.EncoderOfError("graphson: unsupported type: %s", typ.String())
}
}

View File

@@ -0,0 +1,16 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEncodeUnsupportedType(t *testing.T) {
_, err := Marshal(func() {})
assert.Error(t, err)
}

View File

@@ -0,0 +1,45 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"unsafe"
jsoniter "github.com/json-iterator/go"
)
// EncoderOfError returns a value encoder which always fails to encode.
func (encodeExtension) EncoderOfError(format string, args ...any) jsoniter.ValEncoder {
return decoratorOfError(format, args...)
}
// DecoderOfError returns a value decoder which always fails to decode.
func (decodeExtension) DecoderOfError(format string, args ...any) jsoniter.ValDecoder {
return decoratorOfError(format, args...)
}
func decoratorOfError(format string, args ...any) errorCodec {
err := fmt.Errorf(format, args...)
return errorCodec{err}
}
type errorCodec struct{ error }
func (ec errorCodec) Encode(_ unsafe.Pointer, stream *jsoniter.Stream) {
if stream.Error == nil {
stream.Error = ec.error
}
}
func (errorCodec) IsEmpty(unsafe.Pointer) bool {
return false
}
func (ec errorCodec) Decode(_ unsafe.Pointer, iter *jsoniter.Iterator) {
if iter.Error == nil {
iter.Error = ec.error
}
}

View File

@@ -0,0 +1,30 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"bytes"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestErrorCodec(t *testing.T) {
codec := errorCodec{errors.New("codec error")}
assert.False(t, codec.IsEmpty(nil))
var buf bytes.Buffer
stream := config.BorrowStream(&buf)
defer config.ReturnStream(stream)
codec.Encode(nil, stream)
assert.Empty(t, buf.Bytes())
assert.EqualError(t, stream.Error, codec.Error())
iter := config.BorrowIterator([]byte{})
defer config.ReturnIterator(iter)
codec.Decode(nil, iter)
assert.EqualError(t, iter.Error, codec.Error())
}

View File

@@ -0,0 +1,88 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"reflect"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
var (
typeEncoders = map[string]jsoniter.ValEncoder{}
typeDecoders = map[string]jsoniter.ValDecoder{}
)
// RegisterTypeEncoder register type encoder for typ.
func RegisterTypeEncoder(typ string, enc jsoniter.ValEncoder) {
typeEncoders[typ] = enc
}
// RegisterTypeDecoder register type decoder for typ.
func RegisterTypeDecoder(typ string, dec jsoniter.ValDecoder) {
typeDecoders[typ] = dec
}
type registeredEncoder struct{ jsoniter.ValEncoder }
// EncoderOfRegistered returns a value encoder of a registered type.
func (encodeExtension) EncoderOfRegistered(typ reflect2.Type) jsoniter.ValEncoder {
enc := typeEncoders[typ.String()]
if enc != nil {
return registeredEncoder{enc}
}
if typ.Kind() == reflect.Ptr {
ptrType := typ.(reflect2.PtrType)
enc := typeEncoders[ptrType.Elem().String()]
if enc != nil {
return registeredEncoder{
ValEncoder: &jsoniter.OptionalEncoder{
ValueEncoder: enc,
},
}
}
}
return nil
}
// DecoratorOfRegistered decorates a value encoder of a registered type.
func (encodeExtension) DecoratorOfRegistered(enc jsoniter.ValEncoder) jsoniter.ValEncoder {
if _, ok := enc.(registeredEncoder); ok {
return enc
}
return nil
}
type registeredDecoder struct{ jsoniter.ValDecoder }
// DecoderOfRegistered returns a value decoder of a registered type.
func (decodeExtension) DecoderOfRegistered(typ reflect2.Type) jsoniter.ValDecoder {
dec := typeDecoders[typ.String()]
if dec != nil {
return registeredDecoder{dec}
}
if typ.Kind() == reflect.Ptr {
ptrType := typ.(reflect2.PtrType)
dec := typeDecoders[ptrType.Elem().String()]
if dec != nil {
return registeredDecoder{
ValDecoder: &jsoniter.OptionalDecoder{
ValueType: ptrType.Elem(),
ValueDecoder: dec,
},
}
}
}
return nil
}
// DecoratorOfRegistered decorates a value decoder of a registered type.
func (decodeExtension) DecoratorOfRegistered(dec jsoniter.ValDecoder) jsoniter.ValDecoder {
if _, ok := dec.(registeredDecoder); ok {
return dec
}
return nil
}

View File

@@ -0,0 +1,16 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
jsoniter "github.com/json-iterator/go"
)
var config = jsoniter.Config{}.Froze()
func init() {
config.RegisterExtension(&encodeExtension{})
config.RegisterExtension(&decodeExtension{})
}

View File

@@ -0,0 +1,153 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"bytes"
"errors"
"fmt"
"io"
"reflect"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// DecoratorOfInterface decorates a value decoder of an interface type.
func (decodeExtension) DecoratorOfInterface(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
if _, ok := typ.(*reflect2.UnsafeEFaceType); ok {
return efaceDecoder{typ, dec}
}
return dec
}
type efaceDecoder struct {
typ reflect2.Type
jsoniter.ValDecoder
}
func (dec efaceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
switch next := iter.WhatIsNext(); next {
case jsoniter.StringValue, jsoniter.BoolValue, jsoniter.NilValue:
dec.ValDecoder.Decode(ptr, iter)
case jsoniter.ObjectValue:
dec.decode(ptr, iter)
default:
iter.ReportError("decode empty interface", fmt.Sprintf("unexpected value type: %d", next))
}
}
func (dec efaceDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
data := iter.SkipAndReturnBytes()
if iter.Error != nil && iter.Error != io.EOF {
return
}
rtype, err := dec.reflectBytes(data)
if err != nil {
iter.ReportError("decode empty interface", err.Error())
return
}
it := config.BorrowIterator(data)
defer config.ReturnIterator(it)
var val any
if rtype != nil {
val = rtype.New()
it.ReadVal(val)
val = rtype.Indirect(val)
} else {
if jsoniter.Get(data, TypeKey).LastError() == nil {
vk := jsoniter.Get(data, ValueKey)
if vk.LastError() == nil {
val = vk.GetInterface()
}
}
if val == nil {
val = it.Read()
}
}
if it.Error != nil && it.Error != io.EOF {
iter.ReportError("decode empty interface", it.Error.Error())
return
}
// nolint: gas
dec.typ.UnsafeSet(ptr, unsafe.Pointer(&val))
}
func (dec efaceDecoder) reflectBytes(data []byte) (reflect2.Type, error) {
typ := Type(jsoniter.Get(data, TypeKey).ToString())
rtype := dec.reflectType(typ)
if rtype != nil {
return rtype, nil
}
switch typ {
case listType:
return dec.reflectSlice(data)
case mapType:
return dec.reflectMap(data)
default:
return nil, nil
}
}
func (efaceDecoder) reflectType(typ Type) reflect2.Type {
switch typ {
case doubleType:
return reflect2.TypeOf(float64(0))
case floatType:
return reflect2.TypeOf(float32(0))
case byteType:
return reflect2.TypeOf(uint8(0))
case int16Type:
return reflect2.TypeOf(int16(0))
case int32Type:
return reflect2.TypeOf(int32(0))
case int64Type, bigIntegerType:
return reflect2.TypeOf(int64(0))
case byteBufferType:
return reflect2.TypeOf([]byte{})
default:
return nil
}
}
func (efaceDecoder) reflectSlice(data []byte) (reflect2.Type, error) {
var elem any
if err := Unmarshal(data, &[...]*any{&elem}); err != nil {
return nil, fmt.Errorf("cannot read first list element: %w", err)
}
if elem == nil {
return reflect2.TypeOf([]any{}), nil
}
sliceType := reflect.SliceOf(reflect.TypeOf(elem))
return reflect2.Type2(sliceType), nil
}
func (efaceDecoder) reflectMap(data []byte) (reflect2.Type, error) {
var key, elem any
if err := Unmarshal(
bytes.Replace(data, []byte(mapType), []byte(listType), 1),
&[...]*any{&key, &elem},
); err != nil {
return nil, fmt.Errorf("cannot unmarshal first map item: %w", err)
}
if key == nil {
return reflect2.TypeOf(map[any]any{}), nil
} else if elem == nil {
return nil, errors.New("expect map element, but found only key")
}
mapType := reflect.MapOf(reflect.TypeOf(key), reflect.TypeOf(elem))
return reflect2.Type2(mapType), nil
}

View File

@@ -0,0 +1,352 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDecodeInterface(t *testing.T) {
tests := []struct {
name string
in string
want any
wantErr bool
}{
{
name: "Boolean",
in: "false",
want: false,
},
{
name: "String",
in: `"str"`,
want: "str",
},
{
name: "Double",
in: `{
"@type": "g:Double",
"@value": 3.14
}`,
want: float64(3.14),
},
{
name: "Float",
in: `{
"@type": "g:Float",
"@value": -22.567
}`,
want: float32(-22.567),
},
{
name: "Int32",
in: `{
"@type": "g:Int32",
"@value": 9000
}`,
want: int32(9000),
},
{
name: "Int64",
in: `{
"@type": "g:Int64",
"@value": 188786
}`,
want: int64(188786),
},
{
name: "BigInteger",
in: `{
"@type": "gx:BigInteger",
"@value": 352353463712
}`,
want: int64(352353463712),
},
{
name: "Byte",
in: `{
"@type": "gx:Byte",
"@value": 100
}`,
want: uint8(100),
},
{
name: "Int16",
in: `{
"@type": "gx:Int16",
"@value": 2000
}`,
want: int16(2000),
},
{
name: "UnknownType",
in: `{
"@type": "g:T",
"@value": "label"
}`,
want: "label",
},
{
name: "UntypedArray",
in: "[]",
wantErr: true,
},
{
name: "NoType",
in: `{
"@typ": "g:Int32",
"@value": 345
}`,
wantErr: true,
},
{
name: "BadObject",
in: `{
"@type": "g:Int32",
"@value": 345
`,
wantErr: true,
},
{
name: "BadList",
in: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int64",
"@val": 123457990
}
]
}`,
wantErr: true,
},
{
name: "BadMap",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@val": 123457990
},
"First"
]
}`,
wantErr: true,
},
{
name: "KeyOnlyMap",
in: `{
"@type": "g:Map",
"@value": ["Key"]
}`,
wantErr: true,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var got any
err := UnmarshalFromString(tc.in, &got)
if !tc.wantErr {
require.NoError(t, err)
assert.Equal(t, tc.want, got)
} else {
assert.Error(t, err)
}
})
}
}
func TestDecodeInterfaceSlice(t *testing.T) {
tests := []struct {
in string
want any
}{
{
in: `{
"@type": "g:List",
"@value": []
}`,
want: []any{},
},
{
in: `{
"@type": "g:List",
"@value": ["x", "y", "z"]
}`,
want: []string{"x", "y", "z"},
},
{
in: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int64",
"@value": 123457990
},
{
"@type": "g:Int64",
"@value": 23456111
},
{
"@type": "g:Int64",
"@value": -687450
}
]
}`,
want: []int64{123457990, 23456111, -687450},
},
{
in: `{
"@type": "gx:ByteBuffer",
"@value": "AQIDBAU="
}`,
want: []byte{1, 2, 3, 4, 5},
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) {
t.Parallel()
var got any
err := UnmarshalFromString(tc.in, &got)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestDecodeInterfaceMap(t *testing.T) {
tests := []struct {
in string
want any
}{
{
in: `{
"@type": "g:Map",
"@value": []
}`,
want: map[any]any{},
},
{
in: `{
"@type": "g:Map",
"@value": [
"Sep",
{
"@type": "g:Int32",
"@value": 9
},
"Oct",
{
"@type": "g:Int32",
"@value": 10
},
"Nov",
{
"@type": "g:Int32",
"@value": 11
}
]
}`,
want: map[string]int32{
"Sep": int32(9),
"Oct": int32(10),
"Nov": int32(11),
},
},
{
in: `{
"@type": "g:Map",
"@value": [
"One",
{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 1
}
]
},
"Two",
{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 2
}
]
},
"Three",
{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 3
}
]
}
]
}`,
want: map[string][]int32{
"One": {1},
"Two": {2},
"Three": {3},
},
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) {
t.Parallel()
var got any
err := UnmarshalFromString(tc.in, &got)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestDecodeInterfaceObject(t *testing.T) {
book := struct {
ID string `json:"id" graphson:"g:UUID"`
Title string `json:"title"`
Author string `json:"author"`
Pages int `json:"num_pages"`
Chapters []string `json:"chapters"`
}{
ID: "21d5dcbf-1fd4-493e-9b74-d6c429f9e4a5",
Title: "The Art of Computer Programming, Vol. 2",
Author: "Donald E. Knuth",
Pages: 784,
Chapters: []string{"Random numbers", "Arithmetic"},
}
data, err := Marshal(book)
require.NoError(t, err)
var v any
err = Unmarshal(data, &v)
require.NoError(t, err)
obj := v.(map[string]any)
assert.Equal(t, book.ID, obj["id"])
assert.Equal(t, book.Title, obj["title"])
assert.Equal(t, book.Author, obj["author"])
assert.EqualValues(t, book.Pages, obj["num_pages"])
assert.ElementsMatch(t, book.Chapters, obj["chapters"])
}

View File

@@ -0,0 +1,74 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"sync"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// LazyEncoderOf returns a lazy encoder for type.
func (encodeExtension) LazyEncoderOf(typ reflect2.Type) jsoniter.ValEncoder {
return &lazyEncoder{resolve: func() jsoniter.ValEncoder {
return config.EncoderOf(typ)
}}
}
// LazyDecoderOf returns a lazy unique decoder for type.
func (decodeExtension) LazyDecoderOf(typ reflect2.Type) jsoniter.ValDecoder {
return &lazyDecoder{resolve: func() jsoniter.ValDecoder {
dec := config.DecoderOf(reflect2.PtrTo(typ))
if td, ok := dec.(typeDecoder); ok {
td.typeChecker = &uniqueType{elemChecker: td.typeChecker}
dec = td
}
return dec
}}
}
type lazyEncoder struct {
jsoniter.ValEncoder
resolve func() jsoniter.ValEncoder
once sync.Once
}
func (enc *lazyEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
enc.once.Do(func() { enc.ValEncoder = enc.resolve() })
enc.ValEncoder.Encode(ptr, stream)
}
func (enc *lazyEncoder) IsEmpty(ptr unsafe.Pointer) bool {
enc.once.Do(func() { enc.ValEncoder = enc.resolve() })
return enc.ValEncoder.IsEmpty(ptr)
}
type lazyDecoder struct {
jsoniter.ValDecoder
resolve func() jsoniter.ValDecoder
once sync.Once
}
func (dec *lazyDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
dec.once.Do(func() { dec.ValDecoder = dec.resolve() })
dec.ValDecoder.Decode(ptr, iter)
}
type uniqueType struct {
typ Type
once sync.Once
elemChecker typeChecker
}
func (u *uniqueType) CheckType(other Type) error {
u.once.Do(func() { u.typ = other })
if u.typ != other {
return fmt.Errorf("expect type %s, but found %s", u.typ, other)
}
return u.elemChecker.CheckType(u.typ)
}

View File

@@ -0,0 +1,46 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"sync/atomic"
"testing"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestLazyEncode(t *testing.T) {
var m mocker
m.On("IsEmpty", mock.Anything).Return(false).Once()
m.On("Encode", mock.Anything, mock.Anything).Once()
defer m.AssertExpectations(t)
var cnt uint32
var enc jsoniter.ValEncoder = &lazyEncoder{resolve: func() jsoniter.ValEncoder {
assert.Equal(t, uint32(1), atomic.AddUint32(&cnt, 1))
return &m
}}
enc.IsEmpty(nil)
enc.Encode(nil, nil)
}
func TestLazyDecode(t *testing.T) {
var m mocker
m.On("Decode", mock.Anything, mock.Anything).Times(3)
defer m.AssertExpectations(t)
var cnt uint32
var dec jsoniter.ValDecoder = &lazyDecoder{resolve: func() jsoniter.ValDecoder {
assert.Equal(t, uint32(1), atomic.AddUint32(&cnt, 1))
return &m
}}
for i := 0; i < 3; i++ {
dec.Decode(nil, nil)
}
}

View File

@@ -0,0 +1,116 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// EncoderOfMap returns a value encoder of a map type.
func (ext encodeExtension) EncoderOfMap(typ reflect2.Type) jsoniter.ValEncoder {
mapType := typ.(reflect2.MapType)
return &mapEncoder{
mapType: mapType,
keyEnc: ext.LazyEncoderOf(mapType.Key()),
elemEnc: ext.LazyEncoderOf(mapType.Elem()),
}
}
// DecoratorOfMap decorates a value encoder of a map type.
func (encodeExtension) DecoratorOfMap(enc jsoniter.ValEncoder) jsoniter.ValEncoder {
return typeEncoder{enc, mapType}
}
type mapEncoder struct {
mapType reflect2.MapType
keyEnc jsoniter.ValEncoder
elemEnc jsoniter.ValEncoder
}
func (enc *mapEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
iter := enc.mapType.UnsafeIterate(ptr)
if !iter.HasNext() {
stream.WriteEmptyArray()
return
}
stream.WriteArrayStart()
for {
key, elem := iter.UnsafeNext()
enc.keyEnc.Encode(key, stream)
stream.WriteMore()
enc.elemEnc.Encode(elem, stream)
if !iter.HasNext() {
break
}
stream.WriteMore()
}
stream.WriteArrayEnd()
}
func (enc *mapEncoder) IsEmpty(ptr unsafe.Pointer) bool {
return !enc.mapType.UnsafeIterate(ptr).HasNext()
}
// DecoderOfMap returns a value decoder of a map type.
func (ext decodeExtension) DecoderOfMap(typ reflect2.Type) jsoniter.ValDecoder {
mapType := typ.(reflect2.MapType)
keyType, elemType := mapType.Key(), mapType.Elem()
return &mapDecoder{
mapType: mapType,
keyType: keyType,
elemType: elemType,
keyDec: ext.LazyDecoderOf(keyType),
elemDec: ext.LazyDecoderOf(elemType),
}
}
// DecoratorOfMap decorates a value decoder of a map type.
func (decodeExtension) DecoratorOfMap(dec jsoniter.ValDecoder) jsoniter.ValDecoder {
return typeDecoder{dec, mapType}
}
type mapDecoder struct {
mapType reflect2.MapType
keyType reflect2.Type
elemType reflect2.Type
keyDec jsoniter.ValDecoder
elemDec jsoniter.ValDecoder
}
func (dec *mapDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
mapType := dec.mapType
if mapType.UnsafeIsNil(ptr) {
mapType.UnsafeSet(ptr, mapType.UnsafeMakeMap(0))
}
var key unsafe.Pointer
if !iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool {
if key == nil {
key = dec.keyType.UnsafeNew()
dec.keyDec.Decode(key, iter)
return iter.Error == nil
}
elem := dec.elemType.UnsafeNew()
dec.elemDec.Decode(elem, iter)
if iter.Error != nil {
return false
}
mapType.UnsafeSetIndex(ptr, key, elem)
key = nil
return true
}) {
return
}
if key != nil {
iter.ReportError("decode map", "odd number of map items")
}
}

View File

@@ -0,0 +1,355 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"strings"
"testing"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeMap(t *testing.T) {
tests := []struct {
name string
in any
want string
}{
{
name: "simple",
in: map[int32]string{
3: "Mar",
1: "Jan",
2: "Feb",
},
want: `[
{
"@type": "g:Int32",
"@value": 1
},
"Jan",
{
"@type": "g:Int32",
"@value": 2
},
"Feb",
{
"@type": "g:Int32",
"@value": 3
},
"Mar"
]`,
},
{
name: "mixed",
in: map[string]any{
"byte": byte('a'),
"string": "str",
"slice": []int{1, 2, 3},
"map": map[string]int{},
},
want: `[
"byte",
{
"@type": "gx:Byte",
"@value": 97
},
"string",
"str",
"slice",
{
"@type": "g:List",
"@value": [
{
"@type": "g:Int64",
"@value": 1
},
{
"@type": "g:Int64",
"@value": 2
},
{
"@type": "g:Int64",
"@value": 3
}
]
},
"map",
{
"@type": "g:Map",
"@value": []
}
]`,
},
{
name: "struct-key",
in: map[struct {
K string `json:"key"`
}]int32{
{"result"}: 42,
},
want: `[
{
"key": "result"
},
{
"@type": "g:Int32",
"@value": 42
}
]`,
},
{
name: "nil",
in: map[string]uint8(nil),
want: "null",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
data, err := Marshal(tc.in)
require.NoError(t, err)
assert.Equal(t, "g:Map", jsoniter.Get(data, "@type").ToString())
var want []any
err = jsoniter.UnmarshalFromString(tc.want, &want)
require.NoError(t, err)
got, ok := jsoniter.Get(data, "@value").GetInterface().([]any)
require.True(t, ok)
assert.ElementsMatch(t, want, got)
})
}
}
func TestDecodeMap(t *testing.T) {
tests := []struct {
name string
in string
want any
}{
{
name: "empty",
in: `{
"@type": "g:Map",
"@value": []
}`,
want: map[int]int{},
},
{
name: "simple",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int32",
"@value": 6
},
"Jun",
{
"@type": "g:Int32",
"@value": 7
},
"Jul",
{
"@type": "g:Int32",
"@value": 8
},
"Aug"
]
}`,
want: map[int]string{
6: "Jun",
7: "Jul",
8: "Aug",
},
},
{
name: "duplicate",
in: `{
"@type": "g:Map",
"@value": [
"Sep",
{
"@type": "g:Int32",
"@value": 9
},
"Oct",
{
"@type": "g:Int32",
"@value": 65
},
"Oct",
{
"@type": "g:Int32",
"@value": 10
},
"Nov",
null
]
}`,
want: map[string]*int{
"Sep": func() *int { v := 9; return &v }(),
"Oct": func() *int { v := 10; return &v }(),
"Nov": nil,
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
typ := reflect2.TypeOf(tc.want).(reflect2.MapType)
got := typ.MakeMap(0)
err := UnmarshalFromString(tc.in, got)
require.NoError(t, err)
assert.Equal(t, tc.want, typ.Indirect(got))
})
}
}
func TestDecodeMapIntoNil(t *testing.T) {
var got map[int64]int32
err := UnmarshalFromString(`{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@value": 9
},
{
"@type": "g:Int32",
"@value": -9
},
{
"@type": "g:Int64",
"@value": 99
},
{
"@type": "g:Int32",
"@value": -99
},
{
"@type": "g:Int64",
"@value": 999
},
{
"@type": "g:Int32",
"@value": -999
}
]
}`, &got)
require.NoError(t, err)
assert.Equal(t, map[int64]int32{9: -9, 99: -99, 999: -999}, got)
}
func TestDecodeBadMap(t *testing.T) {
tests := []struct {
name string
in string
}{
{
name: "BadValue",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@value": 9
},
{
"@type": "g:Int32",
"@value": "55"
}
]
}`,
},
{
name: "NoValue",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@value": 9
},
{
"@type": "g:Int32",
"@value": 9
},
{
"@type": "g:Int64",
"@value": 42
}
]
}`,
},
{
name: "AlterKeyType",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@value": 9
},
{
"@type": "g:Int32",
"@value": 9
},
{
"@type": "g:Int32",
"@value": 42
},
{
"@type": "g:Int32",
"@value": 42
}
]
}`,
},
{
name: "AlterValType",
in: `{
"@type": "g:Map",
"@value": [
{
"@type": "g:Int64",
"@value": 9
},
{
"@type": "g:Int32",
"@value": 9
},
{
"@type": "g:Int64",
"@value": 42
},
{
"@type": "g:Int64",
"@value": 42
}
]
}`,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var v map[int]int
err := NewDecoder(strings.NewReader(tc.in)).Decode(&v)
assert.Error(t, err)
})
}
}

View File

@@ -0,0 +1,133 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"io"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// DecoratorOfMarshaler decorates a value encoder of a Marshaler interface.
func (ext encodeExtension) DecoratorOfMarshaler(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
if typ == marshalerType {
enc := marshalerEncoder{enc, typ}
return directMarshalerEncoder{enc}
}
if typ.Implements(marshalerType) {
return marshalerEncoder{enc, typ}
}
ptrType := reflect2.PtrTo(typ)
if ptrType.Implements(marshalerType) {
ptrEnc := ext.LazyEncoderOf(ptrType)
enc := marshalerEncoder{ptrEnc, ptrType}
return referenceEncoder{enc}
}
return nil
}
// DecoderOfUnmarshaler returns a value decoder of an Unmarshaler interface.
func (decodeExtension) DecoderOfUnmarshaler(typ reflect2.Type) jsoniter.ValDecoder {
ptrType := reflect2.PtrTo(typ)
if ptrType.Implements(unmarshalerType) {
return referenceDecoder{
unmarshalerDecoder{ptrType},
}
}
return nil
}
// DecoratorOfUnmarshaler decorates a value encoder of an Unmarshaler interface.
func (decodeExtension) DecoratorOfUnmarshaler(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
if reflect2.PtrTo(typ).Implements(unmarshalerType) {
return dec
}
return nil
}
var (
marshalerType = reflect2.TypeOfPtr((*Marshaler)(nil)).Elem()
unmarshalerType = reflect2.TypeOfPtr((*Unmarshaler)(nil)).Elem()
)
type marshalerEncoder struct {
jsoniter.ValEncoder
reflect2.Type
}
func (enc marshalerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
marshaler := enc.Type.UnsafeIndirect(ptr).(Marshaler)
enc.encode(marshaler, stream)
}
func (enc marshalerEncoder) encode(marshaler Marshaler, stream *jsoniter.Stream) {
data, err := marshaler.MarshalGraphson()
if err != nil {
stream.Error = fmt.Errorf("graphson: error calling MarshalGraphson for type %s: %w", enc.Type, err)
return
}
if !config.Valid(data) {
stream.Error = fmt.Errorf("graphson: syntax error when marshaling type %s", enc.Type)
return
}
_, stream.Error = stream.Write(data)
}
type directMarshalerEncoder struct {
marshalerEncoder
}
func (enc directMarshalerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
marshaler := *(*Marshaler)(ptr)
enc.encode(marshaler, stream)
}
type referenceEncoder struct {
jsoniter.ValEncoder
}
func (enc referenceEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
// nolint: gas
enc.ValEncoder.Encode(unsafe.Pointer(&ptr), stream)
}
func (enc referenceEncoder) IsEmpty(ptr unsafe.Pointer) bool {
// nolint: gas
return enc.ValEncoder.IsEmpty(unsafe.Pointer(&ptr))
}
type unmarshalerDecoder struct {
reflect2.Type
}
func (dec unmarshalerDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
bytes := iter.SkipAndReturnBytes()
if iter.Error != nil && iter.Error != io.EOF {
return
}
unmarshaler := dec.UnsafeIndirect(ptr).(Unmarshaler)
if err := unmarshaler.UnmarshalGraphson(bytes); err != nil {
iter.ReportError(
"unmarshal graphson",
fmt.Sprintf(
"graphson: error calling UnmarshalGraphson for type %s: %s",
dec.Type, err,
),
)
}
}
type referenceDecoder struct {
jsoniter.ValDecoder
}
func (dec referenceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
// nolint: gas
dec.ValDecoder.Decode(unsafe.Pointer(&ptr), iter)
}

View File

@@ -0,0 +1,101 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"errors"
"fmt"
"reflect"
"testing"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestMarshalerEncode(t *testing.T) {
want := []byte(`{"@type": "g:Int32", "@value": 42}`)
m := &mocker{}
call := m.On("MarshalGraphson").Return(want, nil)
defer m.AssertExpectations(t)
tests := []any{m, &m, func() *Marshaler { marshaler := Marshaler(m); return &marshaler }(), Marshaler(nil)}
call.Times(len(tests) - 1)
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc), func(t *testing.T) {
got, err := Marshal(tc)
assert.NoError(t, err)
if !reflect2.IsNil(tc) {
assert.Equal(t, want, got)
} else {
assert.Equal(t, []byte("null"), got)
}
})
}
}
func TestMarshalerError(t *testing.T) {
errStr := "marshaler error"
m := &mocker{}
m.On("MarshalGraphson").Return(nil, errors.New(errStr)).Once()
defer m.AssertExpectations(t)
_, err := Marshal(m)
assert.Error(t, err)
assert.Contains(t, err.Error(), errStr)
}
func TestBadMarshaler(t *testing.T) {
m := &mocker{}
m.On("MarshalGraphson").Return([]byte(`{"@type": "g:Int32", "@value":`), nil).Once()
defer m.AssertExpectations(t)
_, err := Marshal(m)
assert.Error(t, err)
}
func TestUnmarshalerDecode(t *testing.T) {
data := `{"@type": "g:UUID", "@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397"}`
var value string
m := &mocker{}
m.On("UnmarshalGraphson", mock.Anything).
Run(func(args mock.Arguments) {
data := args.Get(0).([]byte)
value = jsoniter.Get(data, "@value").ToString()
}).
Return(nil).
Once()
defer m.AssertExpectations(t)
err := UnmarshalFromString(data, m)
require.NoError(t, err)
assert.Equal(t, "cb682578-9d92-4499-9ebc-5c6aa73c5397", value)
}
func TestUnmarshalerError(t *testing.T) {
errStr := "unmarshaler error"
m := &mocker{}
m.On("UnmarshalGraphson", mock.Anything).Return(errors.New(errStr)).Once()
defer m.AssertExpectations(t)
err := Unmarshal([]byte(`{}`), m)
require.Error(t, err)
assert.Contains(t, err.Error(),
fmt.Sprintf("graphson: error calling UnmarshalGraphson for type %s: %s",
reflect.TypeOf(m), errStr,
),
)
}
func TestUnmarshalBadInput(t *testing.T) {
m := &mocker{}
defer m.AssertExpectations(t)
err := UnmarshalFromString(`{"@type"}`, m)
assert.Error(t, err)
}

View File

@@ -0,0 +1,137 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"io"
"math"
"reflect"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// EncoderOfNative returns a value encoder of a native type.
func (encodeExtension) EncoderOfNative(typ reflect2.Type) jsoniter.ValEncoder {
switch typ.Kind() {
case reflect.Float64:
return float64Encoder{typ}
default:
return nil
}
}
// DecoratorOfNative decorates a value encoder of a native type.
func (encodeExtension) DecoratorOfNative(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
switch typ.Kind() {
case reflect.Bool, reflect.String:
return enc
case reflect.Int64, reflect.Int, reflect.Uint32:
return typeEncoder{enc, int64Type}
case reflect.Int32, reflect.Int8, reflect.Uint16:
return typeEncoder{enc, int32Type}
case reflect.Int16:
return typeEncoder{enc, int16Type}
case reflect.Uint64, reflect.Uint:
return typeEncoder{enc, bigIntegerType}
case reflect.Uint8:
return typeEncoder{enc, byteType}
case reflect.Float32:
return typeEncoder{enc, floatType}
case reflect.Float64:
return typeEncoder{enc, doubleType}
default:
return nil
}
}
// DecoderOfNative returns a value decoder of a native type.
func (decodeExtension) DecoderOfNative(typ reflect2.Type) jsoniter.ValDecoder {
switch typ.Kind() {
case reflect.Float64:
return float64Decoder{typ}
default:
return nil
}
}
// DecoratorOfNative decorates a value decoder of a native type.
func (decodeExtension) DecoratorOfNative(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
switch typ.Kind() {
case reflect.Bool:
return dec
case reflect.String:
return typeDecoder{dec, typeCheckerFunc(func(Type) error { return nil })}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return typeDecoder{dec, integerTypes}
case reflect.Float32:
return typeDecoder{dec, floatTypes}
case reflect.Float64:
return typeDecoder{dec, doubleTypes}
default:
return nil
}
}
type float64Encoder struct {
reflect2.Type
}
func (enc float64Encoder) IsEmpty(ptr unsafe.Pointer) bool {
return enc.UnsafeIndirect(ptr).(float64) == 0
}
func (enc float64Encoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
f := enc.UnsafeIndirect(ptr).(float64)
switch {
case math.IsNaN(f):
stream.WriteString("NaN")
case math.IsInf(f, 1):
stream.WriteString("Infinity")
case math.IsInf(f, -1):
stream.WriteString("-Infinity")
default:
stream.WriteFloat64(f)
}
}
type float64Decoder struct {
reflect2.Type
}
var (
integerTypes = Types{byteType, int16Type, int32Type, int64Type, bigIntegerType}
floatTypes = append(integerTypes, floatType, bigDecimal)
doubleTypes = append(floatTypes, doubleType)
)
func (dec float64Decoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
var val float64
switch next := iter.WhatIsNext(); next {
case jsoniter.NumberValue:
val = iter.ReadFloat64()
case jsoniter.StringValue:
switch str := iter.ReadString(); str {
case "NaN":
val = math.NaN()
case "Infinity":
val = math.Inf(1)
case "-Infinity":
val = math.Inf(-1)
default:
iter.ReportError("decode float64", "invalid value "+str)
}
default:
iter.ReportError("decode float64", fmt.Sprintf("unexpected value type: %d", next))
}
if iter.Error == nil || iter.Error == io.EOF {
// nolint: gas
dec.UnsafeSet(ptr, unsafe.Pointer(&val))
}
}

View File

@@ -0,0 +1,299 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"math"
"testing"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeNative(t *testing.T) {
tests := []struct {
in any
want string
wantErr bool
}{
{
in: true,
want: "true",
},
{
in: "hello",
want: `"hello"`,
},
{
in: int8(120),
want: `{
"@type": "g:Int32",
"@value": 120
}`,
},
{
in: int16(-16),
want: `{
"@type": "gx:Int16",
"@value": -16
}`,
},
{
in: int32(3232),
want: `{
"@type": "g:Int32",
"@value": 3232
}`,
},
{
in: int64(646464),
want: `{
"@type": "g:Int64",
"@value": 646464
}`,
},
{
in: int(127001),
want: `{
"@type": "g:Int64",
"@value": 127001
}`,
},
{
in: uint8(81),
want: `{
"@type": "gx:Byte",
"@value": 81
}`,
},
{
in: uint16(12345),
want: `{
"@type": "g:Int32",
"@value": 12345
}`,
},
{
in: uint32(123454321),
want: `{
"@type": "g:Int64",
"@value": 123454321
}`,
},
{
in: uint64(1234567890),
want: `{
"@type": "gx:BigInteger",
"@value": 1234567890
}`,
},
{
in: uint(9876543210),
want: `{
"@type" :"gx:BigInteger",
"@value": 9876543210
}`,
},
{
in: float32(math.Pi),
want: `{
"@type": "g:Float",
"@value": 3.1415927
}`,
},
{
in: float64(math.E),
want: `{
"@type": "g:Double",
"@value": 2.718281828459045
}`,
},
{
in: math.NaN(),
want: `{
"@type": "g:Double",
"@value": "NaN"
}`,
},
{
in: math.Inf(1),
want: `{
"@type": "g:Double",
"@value": "Infinity"
}`,
},
{
in: math.Inf(-1),
want: `{
"@type": "g:Double",
"@value": "-Infinity"
}`,
},
{
in: func() *int { v := 7142; return &v }(),
want: `{
"@type": "g:Int64",
"@value": 7142
}`,
},
{
in: func() any { v := int16(6116); return &v }(),
want: `{
"@type": "gx:Int16",
"@value": 6116
}`,
},
{
in: nil,
want: "null",
},
{
in: make(chan int),
wantErr: true,
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.in), func(t *testing.T) {
t.Parallel()
got, err := MarshalToString(tc.in)
if !tc.wantErr {
assert.NoError(t, err)
assert.JSONEq(t, tc.want, got)
} else {
assert.Error(t, err)
assert.Empty(t, got)
}
})
}
}
func TestDecodeNative(t *testing.T) {
tests := []struct {
in string
want any
}{
{
in: `{"@type": "g:Float", "@value": 3.14}`,
want: float32(3.14),
},
{
in: `{"@type": "g:Float", "@value": "Float"}`,
},
{
in: `{"@type": "g:Double", "@value": 2.71}`,
want: float64(2.71),
},
{
in: `{"@type": "gx:BigDecimal", "@value": 3.142}`,
want: float32(3.142),
},
{
in: `{"@type": "gx:BigDecimal", "@value": 55512.5176}`,
want: float64(55512.5176),
},
{
in: `{"@type": "g:T", "@value": "world"}`,
want: "world",
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) {
t.Parallel()
if tc.want != nil {
typ := reflect2.TypeOf(tc.want)
got := typ.New()
err := UnmarshalFromString(tc.in, got)
require.NoError(t, err)
assert.Equal(t, tc.want, typ.Indirect(got))
} else {
var msg jsoniter.RawMessage
err := UnmarshalFromString(tc.in, &msg)
assert.Error(t, err)
}
})
}
}
func TestDecodeTypeMismatch(t *testing.T) {
t.Run("FloatToInt", func(t *testing.T) {
var v int
err := UnmarshalFromString(`{"@type": "g:Float", "@value": 3.14}`, &v)
assert.Error(t, err)
})
t.Run("DoubleToFloat", func(t *testing.T) {
var v float32
err := UnmarshalFromString(`{"@type": "g:Double", "@value": 5.51}`, &v)
assert.Error(t, err)
})
t.Run("BigDecimalToUint64", func(t *testing.T) {
var v uint64
err := UnmarshalFromString(`{"@type": "gx:BigDecimal", "@value": 5645.51834}`, &v)
assert.Error(t, err)
})
}
func TestDecodeNaNInfinity(t *testing.T) {
tests := []struct {
data []byte
expect func(*testing.T, float64, error)
}{
{
data: []byte(`{"@type": "g:Double", "@value": "NaN"}`),
expect: func(t *testing.T, f float64, err error) {
assert.NoError(t, err)
assert.True(t, math.IsNaN(f))
},
},
{
data: []byte(`{"@type": "g:Double", "@value": "Infinity"}`),
expect: func(t *testing.T, f float64, err error) {
assert.NoError(t, err)
assert.True(t, math.IsInf(f, 1))
},
},
{
data: []byte(`{"@type": "g:Double", "@value": "-Infinity"}`),
expect: func(t *testing.T, f float64, err error) {
assert.NoError(t, err)
assert.True(t, math.IsInf(f, -1))
},
},
{
data: []byte(`{"@type": "g:Double", "@value": "Junk"}`),
expect: func(t *testing.T, _ float64, err error) {
assert.Error(t, err)
},
},
{
data: []byte(`{"@type": "g:Double", "@value": [42]}`),
expect: func(t *testing.T, _ float64, err error) {
assert.Error(t, err)
},
},
}
for _, tc := range tests {
var f float64
err := Unmarshal(tc.data, &f)
tc.expect(t, f, err)
}
}
func TestDecodeTypeDefinition(t *testing.T) {
type Status int
const StatusOk Status = 42
var status Status
err := UnmarshalFromString(`{"@type": "g:Int64", "@value": 42}`, &status)
assert.NoError(t, err)
assert.Equal(t, StatusOk, status)
}

View File

@@ -0,0 +1,35 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"errors"
)
// RawMessage is a raw encoded graphson value.
type RawMessage []byte
// RawMessage must implement Marshaler/Unmarshaler interfaces.
var (
_ Marshaler = (*RawMessage)(nil)
_ Unmarshaler = (*RawMessage)(nil)
)
// MarshalGraphson returns m as the graphson encoding of m.
func (m RawMessage) MarshalGraphson() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
return m, nil
}
// UnmarshalGraphson sets *m to a copy of data.
func (m *RawMessage) UnmarshalGraphson(data []byte) error {
if m == nil {
return errors.New("graphson.RawMessage: UnmarshalGraphson on nil pointer")
}
*m = append((*m)[0:0], data...)
return nil
}

View File

@@ -0,0 +1,32 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRawMessageEncoding(t *testing.T) {
var s struct{ M RawMessage }
got, err := MarshalToString(s)
require.NoError(t, err)
assert.Equal(t, `{"M":null}`, got)
s.M = []byte(`"155a"`)
got, err = MarshalToString(s)
require.NoError(t, err)
assert.JSONEq(t, `{"M": "155a"}`, got)
err = (*RawMessage)(nil).UnmarshalGraphson(s.M)
assert.Error(t, err)
s.M = nil
err = UnmarshalFromString(got, &s)
require.NoError(t, err)
assert.Equal(t, `"155a"`, string(s.M))
}

View File

@@ -0,0 +1,142 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"io"
"reflect"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// DecoratorOfSlice decorates a value encoder of a slice type.
func (encodeExtension) DecoratorOfSlice(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
encoder := typeEncoder{ValEncoder: enc}
sliceType := typ.(reflect2.SliceType)
if sliceType.Elem().Kind() == reflect.Uint8 {
encoder.Type = byteBufferType
} else {
encoder.Type = listType
}
return sliceEncoder{sliceType, encoder}
}
// DecoratorOfArray decorates a value encoder of an array type.
func (encodeExtension) DecoratorOfArray(enc jsoniter.ValEncoder) jsoniter.ValEncoder {
return typeEncoder{enc, listType}
}
// DecoderOfSlice returns a value decoder of a slice type.
func (ext decodeExtension) DecoderOfSlice(typ reflect2.Type) jsoniter.ValDecoder {
sliceType := typ.(reflect2.SliceType)
elemType := sliceType.Elem()
if elemType.Kind() == reflect.Uint8 {
return nil
}
return sliceDecoder{
sliceType: sliceType,
elemDec: ext.LazyDecoderOf(elemType),
}
}
// DecoderOfArray returns a value decoder of an array type.
func (ext decodeExtension) DecoderOfArray(typ reflect2.Type) jsoniter.ValDecoder {
arrayType := typ.(reflect2.ArrayType)
return arrayDecoder{
arrayType: arrayType,
elemDec: ext.LazyDecoderOf(arrayType.Elem()),
}
}
// DecoratorOfSlice decorates a value decoder of a slice type.
func (ext decodeExtension) DecoratorOfSlice(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
if typ.(reflect2.SliceType).Elem().Kind() == reflect.Uint8 {
return typeDecoder{dec, byteBufferType}
}
return typeDecoder{dec, listType}
}
// DecoratorOfArray decorates a value decoder of an array type.
func (ext decodeExtension) DecoratorOfArray(dec jsoniter.ValDecoder) jsoniter.ValDecoder {
return typeDecoder{dec, listType}
}
type sliceEncoder struct {
sliceType reflect2.SliceType
jsoniter.ValEncoder
}
func (enc sliceEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
if enc.sliceType.UnsafeIsNil(ptr) {
stream.WriteNil()
} else {
enc.ValEncoder.Encode(ptr, stream)
}
}
type sliceDecoder struct {
sliceType reflect2.SliceType
elemDec jsoniter.ValDecoder
}
func (dec sliceDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
dec.decode(ptr, iter)
if iter.Error != nil && iter.Error != io.EOF {
iter.Error = fmt.Errorf("decoding slice %s: %w", dec.sliceType, iter.Error)
}
}
func (dec sliceDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
sliceType := dec.sliceType
if iter.ReadNil() {
sliceType.UnsafeSetNil(ptr)
return
}
sliceType.UnsafeSet(ptr, sliceType.UnsafeMakeSlice(0, 0))
var length int
iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool {
idx := length
length++
sliceType.UnsafeGrow(ptr, length)
elem := sliceType.UnsafeGetIndex(ptr, idx)
dec.elemDec.Decode(elem, iter)
return iter.Error == nil
})
}
type arrayDecoder struct {
arrayType reflect2.ArrayType
elemDec jsoniter.ValDecoder
}
func (dec arrayDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
dec.decode(ptr, iter)
if iter.Error != nil && iter.Error != io.EOF {
iter.Error = fmt.Errorf("decoding array %s: %w", dec.arrayType, iter.Error)
}
}
func (dec arrayDecoder) decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
var (
arrayType = dec.arrayType
length int
)
iter.ReadArrayCB(func(iter *jsoniter.Iterator) bool {
if length < arrayType.Len() {
idx := length
length++
elem := arrayType.UnsafeGetIndex(ptr, idx)
dec.elemDec.Decode(elem, iter)
} else {
iter.Skip()
}
return iter.Error == nil
})
}

View File

@@ -0,0 +1,219 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"bytes"
"fmt"
"strings"
"testing"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeArray(t *testing.T) {
t.Parallel()
got, err := MarshalToString([...]string{"a", "b", "c"})
require.NoError(t, err)
want := `{ "@type": "g:List", "@value": ["a", "b", "c"]}`
assert.JSONEq(t, want, got)
}
func TestEncodeSlice(t *testing.T) {
tests := []struct {
in any
want string
}{
{
in: []int32{5, 6, 7, 8},
want: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 5
},
{
"@type": "g:Int32",
"@value": 6
},
{
"@type": "g:Int32",
"@value": 7
},
{
"@type": "g:Int32",
"@value": 8
}
]
}`,
},
{
in: []byte{1, 2, 3, 4, 5},
want: `{
"@type": "gx:ByteBuffer",
"@value": "AQIDBAU="
}`,
},
{
in: [...]byte{4, 5},
want: `{
"@type": "g:List",
"@value": [
{
"@type": "gx:Byte",
"@value": 4
},
{
"@type": "gx:Byte",
"@value": 5
}
]
}`,
},
{
in: []uint64(nil),
want: "null",
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.in), func(t *testing.T) {
t.Parallel()
var got bytes.Buffer
err := NewEncoder(&got).Encode(tc.in)
assert.NoError(t, err)
assert.JSONEq(t, tc.want, got.String())
})
}
}
func TestDecodeSlice(t *testing.T) {
tests := []struct {
in string
want any
}{
{
in: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 3
},
{
"@type": "g:Int32",
"@value": -2
},
{
"@type": "g:Int32",
"@value": 1
}
]
}`,
want: []int32{3, -2, 1},
},
{
in: `{
"@type": "g:List",
"@value": ["a", "b", "c"]
}`,
want: []string{"a", "b", "c"},
},
{
in: `{
"@type": "gx:ByteBuffer",
"@value": "AQIDBAU="
}`,
want: []byte{1, 2, 3, 4, 5},
},
{
in: `{
"@type": "g:List",
"@value": [
{
"@type": "gx:Byte",
"@value": 42
},
{
"@type": "gx:Byte",
"@value": 55
},
{
"@type": "gx:Byte",
"@value": 94
}
]
}`,
want: [...]byte{42, 55},
},
}
for _, tc := range tests {
tc := tc
t.Run(fmt.Sprintf("%T", tc.want), func(t *testing.T) {
t.Parallel()
typ := reflect2.TypeOf(tc.want)
got := typ.New()
err := NewDecoder(strings.NewReader(tc.in)).Decode(got)
require.NoError(t, err)
assert.Equal(t, tc.want, typ.Indirect(got))
})
}
}
func TestDecodeBadSlice(t *testing.T) {
tests := []struct {
name string
in string
new func() any
}{
{
name: "TypeMismatch",
in: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 3
},
{
"@type": "g:Int64",
"@value": 2
}
]
}`,
new: func() any { return &[]int{} },
},
{
name: "BadValue",
in: `{
"@type": "g:List",
"@value": [
{
"@type": "g:Int32",
"@value": 3
},
{
"@type": "g:Int32",
"@value": "2"
}
]
}`,
new: func() any { return &[2]int{} },
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := NewDecoder(strings.NewReader(tc.in)).Decode(tc.new())
assert.Error(t, err)
})
}
}

View File

@@ -0,0 +1,39 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import jsoniter "github.com/json-iterator/go"
// DecoratorOfStructField decorates a struct field value encoder.
func (encodeExtension) DecoratorOfStructField(enc jsoniter.ValEncoder, tag string) jsoniter.ValEncoder {
typ, _ := parseTag(tag)
if typ == "" {
return nil
}
encoder, ok := enc.(typeEncoder)
if !ok {
encoder = typeEncoder{ValEncoder: enc}
}
encoder.Type = Type(typ)
return encoder
}
// DecoratorOfStructField decorates a struct field value decoder.
func (decodeExtension) DecoratorOfStructField(dec jsoniter.ValDecoder, tag string) jsoniter.ValDecoder {
typ, _ := parseTag(tag)
if typ == "" {
return nil
}
decoder, ok := dec.(typeDecoder)
if !ok {
decoder = typeDecoder{ValDecoder: dec}
}
decoder.typeChecker = Type(typ)
return decoder
}

View File

@@ -0,0 +1,205 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeStruct(t *testing.T) {
tests := []struct {
name string
in any
want string
}{
{
name: "Simple",
in: struct {
S string
I int
}{
S: "string",
I: 1000,
},
want: `{
"S":"string",
"I": {
"@type": "g:Int64",
"@value": 1000
}
}`,
},
{
name: "Tagged",
in: struct {
ID string `json:"requestId" graphson:"g:UUID"`
Seq int `json:"seq" graphson:"g:Int32"`
Op string `json:"op" graphson:","`
Args map[string]string `json:"args"`
}{
ID: "cb682578-9d92-4499-9ebc-5c6aa73c5397",
Seq: 42,
Op: "authentication",
Args: map[string]string{
"sasl": "AHN0ZXBocGhlbgBwYXNzd29yZA==",
},
},
want: `{
"requestId": {
"@type": "g:UUID",
"@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397"
},
"seq": {
"@type": "g:Int32",
"@value": 42
},
"op": "authentication",
"args": {
"@type": "g:Map",
"@value": ["sasl", "AHN0ZXBocGhlbgBwYXNzd29yZA=="]
}
}`,
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
data, err := MarshalToString(tc.in)
require.NoError(t, err)
assert.JSONEq(t, tc.want, data)
})
}
}
func TestEncodeNestedStruct(t *testing.T) {
type S struct {
Parent *S `json:"parent,omitempty"`
ID int `json:"id" graphson:"g:Int32"`
}
v := S{Parent: &S{ID: 1}, ID: 2}
want := `{
"id": {
"@type": "g:Int32",
"@value": 2
},
"parent": {
"id": {
"@type": "g:Int32",
"@value": 1
}
}
}`
got, err := MarshalToString(&v)
require.NoError(t, err)
assert.JSONEq(t, want, got)
}
func TestDecodeStruct(t *testing.T) {
tests := []struct {
name string
in string
want any
}{
{
name: "Simple",
in: `{
"S":"str",
"I": {
"@type": "g:Int32",
"@value": 9999
}
}`,
want: struct {
S string
I int32
}{
S: "str",
I: 9999,
},
},
{
name: "Tagged",
in: `{
"requestId": {
"@type": "g:UUID",
"@value": "cb682578-9d92-4499-9ebc-5c6aa73c5397"
},
"seq": {
"@type": "g:Int32",
"@value": 42
},
"op": "authentication",
"args": {
"@type": "g:Map",
"@value": ["sasl", "AHN0ZXBocGhlbgBwYXNzd29yZA=="]
}
}`,
want: struct {
ID string `json:"requestId" graphson:"g:UUID"`
Seq int `json:"seq" graphson:"g:Int32"`
Op string `json:"op" graphson:","`
Args map[string]string `json:"args"`
}{
ID: "cb682578-9d92-4499-9ebc-5c6aa73c5397",
Seq: 42,
Op: "authentication",
Args: map[string]string{
"sasl": "AHN0ZXBocGhlbgBwYXNzd29yZA==",
},
},
},
{
name: "Empty",
in: `{}`,
want: struct{}{},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
typ := reflect2.TypeOf(tc.want)
got := typ.New()
err := UnmarshalFromString(tc.in, got)
require.NoError(t, err)
assert.Equal(t, tc.want, typ.Indirect(got))
})
}
}
func TestDecodeNestedStruct(t *testing.T) {
type S struct {
Parent *S `json:"parent,omitempty"`
ID int `json:"id" graphson:"g:Int32"`
}
in := `{
"id": {
"@type": "g:Int32",
"@value": 37
},
"parent": {
"id": {
"@type": "g:Int32",
"@value": 65
}
}
}`
var got S
err := UnmarshalFromString(in, &got)
require.NoError(t, err)
want := S{Parent: &S{ID: 65}, ID: 37}
assert.Equal(t, want, got)
}

View File

@@ -0,0 +1,39 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"strings"
)
type tagOptions string
// parseTag splits a struct field's graphson tag into its type and
// comma-separated options.
func parseTag(tag string) (string, tagOptions) {
if idx := strings.Index(tag, ","); idx != -1 {
return tag[:idx], tagOptions(tag[idx+1:])
}
return tag, ""
}
// Contains reports whether a comma-separated list of options
// contains a particular substr flag. substr must be surrounded by a
// string boundary or commas.
func (opts tagOptions) Contains(opt string) bool {
s := string(opts)
for s != "" {
var next string
i := strings.Index(s, ",")
if i >= 0 {
s, next = s[:i], s[i+1:]
}
if s == opt {
return true
}
s = next
}
return false
}

View File

@@ -0,0 +1,60 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseTag(t *testing.T) {
tests := []struct {
name string
tag string
typ string
opts tagOptions
}{
{
name: "Empty",
},
{
name: "TypeOnly",
tag: "g:Int32",
typ: "g:Int32",
},
{
name: "OptsOnly",
tag: ",opt1,opt2",
opts: "opt1,opt2",
},
{
name: "TypeAndOpts",
tag: "g:UUID,opt3,opt4",
typ: "g:UUID",
opts: "opt3,opt4",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
typ, opts := parseTag(tc.tag)
assert.Equal(t, tc.typ, typ)
assert.Equal(t, tc.opts, opts)
})
}
}
func TestTagOptionsContains(t *testing.T) {
_, opts := parseTag(",opt1,opt2,opt3")
assert.True(t, opts.Contains("opt1"))
assert.True(t, opts.Contains("opt2"))
assert.True(t, opts.Contains("opt3"))
assert.False(t, opts.Contains("opt4"))
assert.False(t, opts.Contains("opt11"))
assert.False(t, opts.Contains(""))
}

View File

@@ -0,0 +1,34 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"time"
"unsafe"
jsoniter "github.com/json-iterator/go"
)
func init() {
RegisterTypeEncoder("time.Time", typeEncoder{timeCodec{}, Timestamp})
RegisterTypeDecoder("time.Time", typeDecoder{timeCodec{}, Types{Timestamp, Date}})
}
type timeCodec struct{}
func (timeCodec) IsEmpty(ptr unsafe.Pointer) bool {
ts := *((*time.Time)(ptr))
return ts.IsZero()
}
func (timeCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
ts := *((*time.Time)(ptr))
stream.WriteInt64(ts.UnixNano() / time.Millisecond.Nanoseconds())
}
func (timeCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
ns := iter.ReadInt64() * time.Millisecond.Nanoseconds()
*((*time.Time)(ptr)) = time.Unix(0, ns)
}

View File

@@ -0,0 +1,36 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTimeEncoding(t *testing.T) {
const ms = 1481750076295
ts := time.Unix(0, ms*time.Millisecond.Nanoseconds())
for _, v := range []any{ts, &ts} {
got, err := MarshalToString(v)
require.NoError(t, err)
assert.JSONEq(t, `{ "@type": "g:Timestamp", "@value": 1481750076295 }`, got)
}
strs := []string{
`{ "@type": "g:Timestamp", "@value": 1481750076295 }`,
`{ "@type": "g:Date", "@value": 1481750076295 }`,
}
for _, str := range strs {
var v time.Time
err := UnmarshalFromString(str, &v)
assert.NoError(t, err)
assert.True(t, ts.Equal(v))
}
}

View File

@@ -0,0 +1,155 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"fmt"
"reflect"
"strings"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
)
// A Type is a graphson type.
type Type string
// graphson typed value types.
const (
// core
doubleType Type = "g:Double"
floatType Type = "g:Float"
int32Type Type = "g:Int32"
int64Type Type = "g:Int64"
listType Type = "g:List"
mapType Type = "g:Map"
Timestamp Type = "g:Timestamp"
Date Type = "g:Date"
// extended
bigIntegerType Type = "gx:BigInteger"
bigDecimal Type = "gx:BigDecimal"
byteType Type = "gx:Byte"
byteBufferType Type = "gx:ByteBuffer"
int16Type Type = "gx:Int16"
)
// String implements fmt.Stringer interface.
func (typ Type) String() string {
return string(typ)
}
// CheckType implements typeChecker interface.
func (typ Type) CheckType(other Type) error {
if typ != other {
return fmt.Errorf("expect type %s, but found %s", typ, other)
}
return nil
}
// Types is a slice of Type.
type Types []Type
// Contains reports whether a slice of types contains a particular type.
func (types Types) Contains(typ Type) bool {
for i := range types {
if types[i] == typ {
return true
}
}
return false
}
// String implements fmt.Stringer interface.
func (types Types) String() string {
var builder strings.Builder
builder.WriteByte('[')
for i := range types {
if i > 0 {
builder.WriteByte(',')
}
builder.WriteString(types[i].String())
}
builder.WriteByte(']')
return builder.String()
}
// CheckType implements typeChecker interface.
func (types Types) CheckType(typ Type) error {
if !types.Contains(typ) {
return fmt.Errorf("expect any of %s, but found %s", types, typ)
}
return nil
}
// Typer is the interface implemented by types that
// define an underlying graphson type.
type Typer interface {
GraphsonType() Type
}
var typerType = reflect2.TypeOfPtr((*Typer)(nil)).Elem()
// DecoratorOfTyper decorates a value encoder of a Typer interface.
func (ext encodeExtension) DecoratorOfTyper(typ reflect2.Type, enc jsoniter.ValEncoder) jsoniter.ValEncoder {
if typ.Kind() != reflect.Struct {
return nil
}
if typ.Implements(typerType) {
return typerEncoder{
typeEncoder: typeEncoder{ValEncoder: enc},
typerOf: func(ptr unsafe.Pointer) Typer {
return typ.UnsafeIndirect(ptr).(Typer)
},
}
}
ptrType := reflect2.PtrTo(typ)
if ptrType.Implements(typerType) {
return typerEncoder{
typeEncoder: typeEncoder{ValEncoder: enc},
typerOf: func(ptr unsafe.Pointer) Typer {
// nolint: gas
return ptrType.UnsafeIndirect(unsafe.Pointer(&ptr)).(Typer)
},
}
}
return nil
}
// DecoratorOfTyper decorates a value decoder of a Typer interface.
func (ext decodeExtension) DecoratorOfTyper(typ reflect2.Type, dec jsoniter.ValDecoder) jsoniter.ValDecoder {
ptrType := reflect2.PtrTo(typ)
if ptrType.Implements(typerType) {
return typerDecoder{
typeDecoder: typeDecoder{ValDecoder: dec},
typerOf: func(ptr unsafe.Pointer) Typer {
// nolint: gas
return ptrType.UnsafeIndirect(unsafe.Pointer(&ptr)).(Typer)
},
}
}
return nil
}
type typerEncoder struct {
typeEncoder
typerOf func(unsafe.Pointer) Typer
}
func (enc typerEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
enc.typeEncoder.Type = enc.typerOf(ptr).GraphsonType()
enc.typeEncoder.Encode(ptr, stream)
}
type typerDecoder struct {
typeDecoder
typerOf func(unsafe.Pointer) Typer
}
func (dec typerDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
dec.typeDecoder.typeChecker = dec.typerOf(ptr).GraphsonType()
dec.typeDecoder.Decode(ptr, iter)
}

View File

@@ -0,0 +1,95 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestTypeCheckType(t *testing.T) {
assert.NoError(t, int32Type.CheckType(int32Type))
assert.Error(t, int32Type.CheckType(int64Type))
}
func TestTypesCheckType(t *testing.T) {
assert.NoError(t, Types{int16Type, int32Type, int64Type}.CheckType(int32Type))
assert.Error(t, Types{floatType, doubleType}.CheckType(bigIntegerType))
}
func TestTypesString(t *testing.T) {
assert.Equal(t, "[]", Types{}.String())
assert.Equal(t, "[gx:Byte]", Types{byteType}.String())
assert.Equal(t, "[gx:Int16,g:Int32,g:Int64]", Types{int16Type, int32Type, int64Type}.String())
}
type vertex struct {
ID int `json:"id"`
Label string `json:"label"`
}
func (vertex) GraphsonType() Type {
return Type("g:Vertex")
}
type mockVertex struct {
mock.Mock `json:"-"`
ID int `json:"id"`
Label string `json:"label"`
}
func (m *mockVertex) GraphsonType() Type {
return m.Called().Get(0).(Type)
}
func TestEncodeTyper(t *testing.T) {
m := &mockVertex{ID: 42, Label: "person"}
m.On("GraphsonType").Return(Type("g:Vertex")).Twice()
defer m.AssertExpectations(t)
v := vertex{ID: m.ID, Label: m.Label}
var vv Typer = v
want := `{
"@type": "g:Vertex",
"@value": {
"id": {
"@type": "g:Int64",
"@value": 42
},
"label": "person"
}
}`
for _, tc := range []any{m, &m, v, vv, &vv} {
got, err := MarshalToString(tc)
assert.NoError(t, err)
assert.JSONEq(t, want, got)
}
}
func TestDecodeTyper(t *testing.T) {
var m mockVertex
m.On("GraphsonType").Return(Type("g:Vertex")).Once()
defer m.AssertExpectations(t)
in := `{
"@type": "g:Vertex",
"@value": {
"id": {
"@type": "g:Int64",
"@value": 55
},
"label": "user"
}
}`
err := UnmarshalFromString(in, &m)
assert.NoError(t, err)
assert.Equal(t, 55, m.ID)
assert.Equal(t, "user", m.Label)
}

View File

@@ -0,0 +1,114 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"errors"
"io"
"unsafe"
jsoniter "github.com/json-iterator/go"
)
// graphson encoding type / value keys
const (
TypeKey = "@type"
ValueKey = "@value"
)
// typeEncoder adds graphson type information to a value encoder.
type typeEncoder struct {
jsoniter.ValEncoder
Type Type
}
// Encode belongs to jsoniter.ValEncoder interface.
func (enc typeEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
stream.WriteObjectStart()
stream.WriteObjectField(TypeKey)
stream.WriteString(enc.Type.String())
stream.WriteMore()
stream.WriteObjectField(ValueKey)
enc.ValEncoder.Encode(ptr, stream)
stream.WriteObjectEnd()
}
type (
// typeDecoder decorates a value decoder and adds graphson type verification.
typeDecoder struct {
jsoniter.ValDecoder
typeChecker
}
// typeChecker defines an interface for graphson type verification.
typeChecker interface {
CheckType(Type) error
}
// typeCheckerFunc allows the use of functions as type checkers.
typeCheckerFunc func(Type) error
// typeValue defines a graphson type / value pair.
typeValue struct {
Type Type
Value jsoniter.RawMessage
}
)
// Decode belongs to jsoniter.ValDecoder interface.
func (dec typeDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
if iter.WhatIsNext() != jsoniter.ObjectValue {
dec.ValDecoder.Decode(ptr, iter)
return
}
data := iter.SkipAndReturnBytes()
if iter.Error != nil && iter.Error != io.EOF {
return
}
var tv typeValue
if err := jsoniter.Unmarshal(data, &tv); err != nil {
iter.ReportError("unmarshal type value", err.Error())
return
}
if err := dec.CheckType(tv.Type); err != nil {
iter.ReportError("check type", err.Error())
return
}
it := config.BorrowIterator(tv.Value)
defer config.ReturnIterator(it)
dec.ValDecoder.Decode(ptr, it)
if it.Error != nil && it.Error != io.EOF {
iter.ReportError("decode value", it.Error.Error())
}
}
// UnmarshalJSON implements json.Unmarshaler interface.
func (tv *typeValue) UnmarshalJSON(data []byte) error {
var v struct {
Type *Type `json:"@type"`
Value jsoniter.RawMessage `json:"@value"`
}
if err := jsoniter.Unmarshal(data, &v); err != nil {
return err
}
if v.Type == nil || v.Value == nil {
return errors.New("missing type or value")
}
tv.Type = *v.Type
tv.Value = v.Value
return nil
}
// CheckType implements typeChecker interface.
func (f typeCheckerFunc) CheckType(typ Type) error {
return f(typ)
}

View File

@@ -0,0 +1,129 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graphson
import (
"bytes"
"errors"
"fmt"
"testing"
"unsafe"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestTypeEncode(t *testing.T) {
var got bytes.Buffer
stream := config.BorrowStream(&got)
defer config.ReturnStream(stream)
typ, val := int32Type, 42
ptr := unsafe.Pointer(&val)
var m mocker
m.On("Encode", ptr, stream).
Run(func(args mock.Arguments) {
stream := args.Get(1).(*jsoniter.Stream)
stream.WriteInt(val)
}).
Once()
defer m.AssertExpectations(t)
typeEncoder{&m, typ}.Encode(ptr, stream)
require.NoError(t, stream.Flush())
want := fmt.Sprintf(`{"@type": "%s", "@value": %d}`, typ, val)
assert.JSONEq(t, want, got.String())
}
func TestTypeDecode(t *testing.T) {
typ, val := int64Type, 84
ptr := unsafe.Pointer(&val)
data := fmt.Sprintf(`{"@value": %d, "@type": "%s"}`, val, typ)
iter := config.BorrowIterator([]byte(data))
defer config.ReturnIterator(iter)
m := &mocker{}
m.On("CheckType", typ).
Return(nil).
Once()
m.On("Decode", ptr, mock.Anything).
Run(func(args mock.Arguments) {
iter := args.Get(1).(*jsoniter.Iterator)
assert.Equal(t, val, iter.ReadInt())
}).
Once()
defer m.AssertExpectations(t)
typeDecoder{m, m}.Decode(ptr, iter)
assert.NoError(t, iter.Error)
}
func TestTypeDecodeBadType(t *testing.T) {
typ, val := int64Type, 55
ptr := unsafe.Pointer(&val)
m := &mocker{}
m.On("CheckType", typ).Return(errors.New("bad type")).Once()
defer m.AssertExpectations(t)
data := fmt.Sprintf(`{"@type": "%s", "@value": %d}`, typ, val)
iter := config.BorrowIterator([]byte(data))
defer config.ReturnIterator(iter)
typeDecoder{m, m}.Decode(ptr, iter)
require.Error(t, iter.Error)
assert.Contains(t, iter.Error.Error(), "bad type")
}
func TestTypeDecodeDuplicateField(t *testing.T) {
data := `{"@type": "gx:Byte", "@value": 33, "@type": "g:Int32"}`
iter := config.BorrowIterator([]byte(data))
defer config.ReturnIterator(iter)
var ptr unsafe.Pointer
m := &mocker{}
m.On("CheckType", mock.MatchedBy(func(typ Type) bool { return typ == int32Type })).
Return(nil).
Once()
m.On("Decode", ptr, mock.Anything).
Run(func(args mock.Arguments) {
args.Get(1).(*jsoniter.Iterator).Skip()
require.NoError(t, iter.Error)
}).
Once()
defer m.AssertExpectations(t)
typeDecoder{m, m}.Decode(ptr, iter)
assert.NoError(t, iter.Error)
}
func TestTypeDecodeMissingField(t *testing.T) {
data := `{"@type": "g:Int32"}`
iter := config.BorrowIterator([]byte(data))
defer config.ReturnIterator(iter)
m := &mocker{}
defer m.AssertExpectations(t)
typeDecoder{m, m}.Decode(nil, iter)
require.Error(t, iter.Error)
assert.Contains(t, iter.Error.Error(), "missing type or value")
}
func TestTypeDecodeSyntaxError(t *testing.T) {
data := `{"@type": "gx:Int16", "@value", 65000}`
iter := config.BorrowIterator([]byte(data))
defer config.ReturnIterator(iter)
m := &mocker{}
defer m.AssertExpectations(t)
typeDecoder{m, m}.Decode(nil, iter)
assert.Error(t, iter.Error)
}

View File

@@ -0,0 +1,30 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package encoding
import (
"bytes"
)
// Mime defines a gremlin mime type.
type Mime []byte
// GraphSON3Mime mime headers.
var (
GraphSON3Mime = NewMime("application/vnd.gremlin-v3.0+json")
)
// NewMime creates a wire format mime header.
func NewMime(s string) Mime {
var buf bytes.Buffer
buf.WriteByte(byte(len(s)))
buf.WriteString(s)
return buf.Bytes()
}
// String implements fmt.Stringer interface.
func (m Mime) String() string {
return string(m[1:])
}

View File

@@ -0,0 +1,21 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package encoding
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewMime(t *testing.T) {
str := "application/vnd.gremlin-v2.0+json"
mime := NewMime(str)
require.Len(t, mime, len(str)+1)
assert.EqualValues(t, len(str), mime[0])
assert.EqualValues(t, str, mime[1:])
assert.Equal(t, str, mime.String())
}

View File

@@ -0,0 +1,46 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"flag"
"log"
"os"
"time"
)
func ExampleClient_Query() {
addr := flag.String("gremlin-server", os.Getenv("GREMLIN_SERVER"), "gremlin server address")
flag.Parse()
if *addr == "" {
log.Fatal("missing gremlin server address")
}
client, err := NewHTTPClient(*addr, nil)
if err != nil {
log.Fatalf("creating client: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
rsp, err := client.Query(ctx, "g.E()")
if err != nil {
log.Fatalf("executing query: %v", err)
}
edges, err := rsp.ReadEdges()
if err != nil {
log.Fatalf("unmashal edges")
}
defer cancel()
for _, e := range edges {
log.Println(e.String())
}
// - Output:
}

47
dialect/gremlin/expand.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"fmt"
"sort"
"strings"
jsoniter "github.com/json-iterator/go"
)
// ExpandBindings expands the given RoundTripper and expands the request bindings into the Gremlin traversal.
func ExpandBindings(rt RoundTripper) RoundTripper {
return RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) {
bindings, ok := r.Arguments[ArgsBindings]
if !ok {
return rt.RoundTrip(ctx, r)
}
query, ok := r.Arguments[ArgsGremlin]
if !ok {
return rt.RoundTrip(ctx, r)
}
{
query, bindings := query.(string), bindings.(map[string]any)
keys := make(sort.StringSlice, 0, len(bindings))
for k := range bindings {
keys = append(keys, k)
}
sort.Sort(sort.Reverse(keys))
kv := make([]string, 0, len(bindings)*2)
for _, k := range keys {
s, err := jsoniter.MarshalToString(bindings[k])
if err != nil {
return nil, fmt.Errorf("marshal bindings value for key %s: %w", k, err)
}
kv = append(kv, k, s)
}
delete(r.Arguments, ArgsBindings)
r.Arguments[ArgsGremlin] = strings.NewReplacer(kv...).Replace(query)
}
return rt.RoundTrip(ctx, r)
})
}

View File

@@ -0,0 +1,71 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandBindings(t *testing.T) {
tests := []struct {
req *Request
wantErr bool
wantQuery string
}{
{
req: NewEvalRequest("no bindings"),
wantQuery: "no bindings",
},
{
req: NewEvalRequest("g.V($0)", WithBindings(map[string]any{"$0": 1})),
wantQuery: "g.V(1)",
},
{
req: NewEvalRequest("g.V().has($1, $2)", WithBindings(map[string]any{"$1": "name", "$2": "a8m"})),
wantQuery: "g.V().has(\"name\", \"a8m\")",
},
{
req: NewEvalRequest("g.V().limit(n)", WithBindings(map[string]any{"n": 10})),
wantQuery: "g.V().limit(10)",
},
{
req: NewEvalRequest("g.V()", WithBindings(map[string]any{"$0": func() {}})),
wantErr: true,
},
{
req: NewEvalRequest("g.V().has($0, $1)", WithBindings(map[string]any{"$0": "active", "$1": true})),
wantQuery: "g.V().has(\"active\", true)",
},
{
req: NewEvalRequest("g.V().has($1, $11)", WithBindings(map[string]any{"$1": "active", "$11": true})),
wantQuery: "g.V().has(\"active\", true)",
},
}
for i, tt := range tests {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
rt := ExpandBindings(RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) {
assert.Equal(t, tt.wantQuery, r.Arguments[ArgsGremlin])
return nil, nil
}))
_, err := rt.RoundTrip(context.Background(), tt.req)
assert.Equal(t, tt.wantErr, err != nil)
})
}
}
func TestExpandBindingsNoQuery(t *testing.T) {
rt := ExpandBindings(RoundTripperFunc(func(ctx context.Context, r *Request) (*Response, error) {
return nil, nil
}))
_, err := rt.RoundTrip(context.Background(), &Request{Arguments: map[string]any{
ArgsBindings: map[string]any{},
}})
assert.NoError(t, err)
}

View File

@@ -0,0 +1,75 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package __
import "entgo.io/ent/dialect/gremlin/graph/dsl"
// As is the api for calling __.As().
func As(args ...any) *dsl.Traversal { return New().As(args...) }
// Is is the api for calling __.Is().
func Is(args ...any) *dsl.Traversal { return New().Is(args...) }
// Not is the api for calling __.Not().
func Not(args ...any) *dsl.Traversal { return New().Not(args...) }
// Has is the api for calling __.Has().
func Has(args ...any) *dsl.Traversal { return New().Has(args...) }
// HasNot is the api for calling __.HasNot().
func HasNot(args ...any) *dsl.Traversal { return New().HasNot(args...) }
// Or is the api for calling __.Or().
func Or(args ...any) *dsl.Traversal { return New().Or(args...) }
// And is the api for calling __.And().
func And(args ...any) *dsl.Traversal { return New().And(args...) }
// In is the api for calling __.In().
func In(args ...any) *dsl.Traversal { return New().In(args...) }
// Out is the api for calling __.Out().
func Out(args ...any) *dsl.Traversal { return New().Out(args...) }
// OutE is the api for calling __.OutE().
func OutE(args ...any) *dsl.Traversal { return New().OutE(args...) }
// InE is the api for calling __.InE().
func InE(args ...any) *dsl.Traversal { return New().InE(args...) }
// InV is the api for calling __.InV().
func InV(args ...any) *dsl.Traversal { return New().InV(args...) }
// V is the api for calling __.V().
func V(args ...any) *dsl.Traversal { return New().V(args...) }
// OutV is the api for calling __.OutV().
func OutV(args ...any) *dsl.Traversal { return New().OutV(args...) }
// Values is the api for calling __.Values().
func Values(args ...string) *dsl.Traversal { return New().Values(args...) }
// Union is the api for calling __.Union().
func Union(args ...any) *dsl.Traversal { return New().Union(args...) }
// Constant is the api for calling __.Constant().
func Constant(args ...any) *dsl.Traversal { return New().Constant(args...) }
// Properties is the api for calling __.Properties().
func Properties(args ...any) *dsl.Traversal { return New().Properties(args...) }
// OtherV is the api for calling __.OtherV().
func OtherV() *dsl.Traversal { return New().OtherV() }
// Count is the api for calling __.Count().
func Count() *dsl.Traversal { return New().Count() }
// Drop is the api for calling __.Drop().
func Drop() *dsl.Traversal { return New().Drop() }
// Fold is the api for calling __.Fold().
func Fold() *dsl.Traversal { return New().Fold() }
func New() *dsl.Traversal { return new(dsl.Traversal).Add(dsl.Token("__")) }

View File

@@ -0,0 +1,214 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
// Package dsl provide an API for writing gremlin dsl queries almost as-is
// in Go without using strings in the code.
//
// Note that, the API is not type-safe and assume the provided query and
// its arguments are valid.
package dsl
import (
"fmt"
"strings"
"time"
)
// Node represents a DSL step in the traversal.
type Node interface {
// Code returns the code representation of the element and its bindings (if any).
Code() (string, []any)
}
type (
// Token holds a simple token, like assignment.
Token string
// List represents a list of elements.
List struct {
Elements []any
}
// Func represents a function call.
Func struct {
Name string
Args []any
}
// Block represents a block/group of nodes.
Block struct {
Nodes []any
}
// Var represents a variable assignment and usage.
Var struct {
Name string
Elem any
}
)
// Code stringified the token.
func (t Token) Code() (string, []any) { return string(t), nil }
// Code returns the code representation of a list.
func (l List) Code() (string, []any) {
c, args := codeList(", ", l.Elements...)
return fmt.Sprintf("[%s]", c), args
}
// Code returns the code representation of a function call.
func (f Func) Code() (string, []any) {
c, args := codeList(", ", f.Args...)
return fmt.Sprintf("%s(%s)", f.Name, c), args
}
// Code returns the code representation of group/block of nodes.
func (b Block) Code() (string, []any) {
return codeList("; ", b.Nodes...)
}
// Code returns the code representation of variable declaration or its identifier.
func (v Var) Code() (string, []any) {
c, args := code(v.Elem)
if v.Name == "" {
return c, args
}
return fmt.Sprintf("%s = %s", v.Name, c), args
}
// predefined nodes.
var (
G = Token("g")
Dot = Token(".")
)
// NewFunc returns a new function node.
func NewFunc(name string, args ...any) *Func {
return &Func{Name: name, Args: args}
}
// NewList returns a new list node.
func NewList(args ...any) *List {
return &List{Elements: args}
}
// Querier is the interface that wraps the Query method.
type Querier interface {
// Query returns the query-string (similar to the Gremlin byte-code) and its bindings.
Query() (string, Bindings)
}
// Bindings are used to associate a variable with a value.
type Bindings map[string]any
// Add adds new value to the bindings map, formats it if needed, and returns its generated name.
func (b Bindings) Add(v any) string {
k := fmt.Sprintf("$%x", len(b))
switch v := v.(type) {
case time.Time:
b[k] = v.UnixNano()
default:
b[k] = v
}
return k
}
// Cardinality of vertex properties.
type Cardinality string
// Cardinality options.
const (
Set Cardinality = "set"
Single Cardinality = "single"
)
// Code implements the Node interface.
func (c Cardinality) Code() (string, []any) { return string(c), nil }
// Keyword defines a Gremlin keyword.
type Keyword string
// Keyword options.
const (
ID Keyword = "id"
)
// Code implements the Node interface.
func (k Keyword) Code() (string, []any) { return string(k), nil }
// Order of vertex properties.
type Order string
// Order options.
const (
Incr Order = "incr"
Decr Order = "decr"
Shuffle Order = "shuffle"
)
// Code implements the Node interface.
func (o Order) Code() (string, []any) { return string(o), nil }
// Column references a particular type of column in a complex data structure such as a Map, a Map.Entry, or a Path.
type Column string
// Column options.
const (
Keys Column = "keys"
Values Column = "values"
)
// Code implements the Node interface.
func (o Column) Code() (string, []any) { return string(o), nil }
// Scope used for steps that have a variable scope which alter the manner in which the step will behave in relation to how the traverses are processed.
type Scope string
// Scope options.
const (
Local Scope = "local"
Global Scope = "global"
)
// Code implements the Node interface.
func (s Scope) Code() (string, []any) { return string(s), nil }
func codeList(sep string, vs ...any) (string, []any) {
var (
br strings.Builder
args []any
)
for i, node := range vs {
if i > 0 {
br.WriteString(sep)
}
c, nargs := code(node)
br.WriteString(c)
args = append(args, nargs...)
}
return br.String(), args
}
func code(v any) (string, []any) {
switch n := v.(type) {
case Node:
return n.Code()
case *Traversal:
var (
b strings.Builder
args []any
)
for i := range n.nodes {
code, nargs := n.nodes[i].Code()
b.WriteString(code)
args = append(args, nargs...)
}
return b.String(), args
default:
return "%s", []any{v}
}
}
func sface(args []string) (v []any) {
for _, s := range args {
v = append(v, s)
}
return
}

View File

@@ -0,0 +1,238 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package dsl_test
import (
"strconv"
"testing"
"entgo.io/ent/dialect/gremlin/graph/dsl"
"entgo.io/ent/dialect/gremlin/graph/dsl/__"
"entgo.io/ent/dialect/gremlin/graph/dsl/g"
"entgo.io/ent/dialect/gremlin/graph/dsl/p"
"github.com/stretchr/testify/require"
)
func TestTraverse(t *testing.T) {
tests := []struct {
input dsl.Querier
wantQuery string
wantBinds dsl.Bindings
}{
{
input: g.V(5),
wantQuery: "g.V($0)",
wantBinds: dsl.Bindings{"$0": 5},
},
{
input: g.V(2).Both("knows"),
wantQuery: "g.V($0).both($1)",
wantBinds: dsl.Bindings{"$0": 2, "$1": "knows"},
},
{
input: g.V(49).BothE("knows").OtherV().ValueMap(),
wantQuery: "g.V($0).bothE($1).otherV().valueMap()",
wantBinds: dsl.Bindings{"$0": 49, "$1": "knows"},
},
{
input: g.AddV("person").Property("name", "a8m").Next(),
wantQuery: "g.addV($0).property($1, $2).next()",
wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m"},
},
{
input: dsl.Each([]any{1, 2, 3}, func(it *dsl.Traversal) *dsl.Traversal {
return g.V(it)
}),
wantQuery: "[$0, $1, $2].each { g.V(it) }",
wantBinds: dsl.Bindings{"$0": 1, "$1": 2, "$2": 3},
},
{
input: dsl.Each([]any{g.V(1).Next()}, func(it *dsl.Traversal) *dsl.Traversal {
return it.ID()
}),
wantQuery: "[g.V($0).next()].each { it.id() }",
wantBinds: dsl.Bindings{"$0": 1},
},
{
input: g.AddV("person").AddE("knows").To(g.V(2)),
wantQuery: "g.addV($0).addE($1).to(g.V($2))",
wantBinds: dsl.Bindings{"$0": "person", "$1": "knows", "$2": 2},
},
{
input: func() *dsl.Traversal {
v1 := g.V(2).Next()
v2 := g.AddV("person").Property("name", "a8m")
e1 := g.V(v1).AddE("knows").To(v2)
return dsl.Group(v1, v2, e1)
}(),
wantQuery: "t0 = g.V($0).next(); t1 = g.addV($1).property($2, $3); t2 = g.V(t0).addE($4).to(t1); t2",
wantBinds: dsl.Bindings{"$0": 2, "$1": "person", "$2": "name", "$3": "a8m", "$4": "knows"},
},
{
input: func() *dsl.Traversal {
v1 := g.AddV("person")
each := dsl.Each([]any{1, 2, 3}, func(it *dsl.Traversal) *dsl.Traversal {
return g.V(v1).AddE("knows").To(g.V(it)).Next()
})
return dsl.Group(v1, each)
}(),
wantQuery: "t0 = g.addV($0); t1 = [$1, $2, $3].each { g.V(t0).addE($4).to(g.V(it)).next() }; t1",
wantBinds: dsl.Bindings{"$0": "person", "$1": 1, "$2": 2, "$3": 3, "$4": "knows"},
},
{
input: g.V().HasLabel("person").
Choose(__.Values("age").Is(p.LTE(20))),
wantQuery: "g.V().hasLabel($0).choose(__.values($1).is(lte($2)))",
wantBinds: dsl.Bindings{"$0": "person", "$1": "age", "$2": 20},
},
{
input: g.AddV("person").Property("name", "a8m").Properties(),
wantQuery: "g.addV($0).property($1, $2).properties()",
wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m"},
},
{
input: func() *dsl.Traversal {
v1 := g.AddV("person").Next()
e1 := g.V(v1).AddE("knows").To(g.V(2).Next())
return dsl.Group(v1, e1, g.V(v1).ValueMap(true))
}(),
wantQuery: "t0 = g.addV($0).next(); t1 = g.V(t0).addE($1).to(g.V($2).next()); t2 = g.V(t0).valueMap($3); t2",
wantBinds: dsl.Bindings{"$0": "person", "$1": "knows", "$2": 2, "$3": true},
},
{
input: func() *dsl.Traversal {
vs := g.V().HasLabel("person").ToList()
edge := g.V(vs).AddE("assoc").To(g.V(1)).Iterate()
each := dsl.Each(vs, func(it *dsl.Traversal) *dsl.Traversal {
return g.V(1).AddE("inverse").To(it).Next()
})
return dsl.Group(vs, edge, each)
}(),
wantQuery: "t0 = g.V().hasLabel($0).toList(); t1 = g.V(t0).addE($1).to(g.V($2)).iterate(); t2 = t0.each { g.V($3).addE($4).to(it).next() }; t2",
wantBinds: dsl.Bindings{"$0": "person", "$1": "assoc", "$2": 1, "$3": 1, "$4": "inverse"},
},
{
input: g.V().Where(__.Or(__.Has("age", 29), __.Has("age", 30))),
wantQuery: "g.V().where(__.or(__.has($0, $1), __.has($2, $3)))",
wantBinds: dsl.Bindings{"$0": "age", "$1": 29, "$2": "age", "$3": 30},
},
{
input: g.V().Has("name", p.Containing("le")).Has("name", p.StartingWith("A")),
wantQuery: `g.V().has($0, containing($1)).has($2, startingWith($3))`,
wantBinds: dsl.Bindings{"$0": "name", "$1": "le", "$2": "name", "$3": "A"},
},
{
input: g.AddV().Property(dsl.Single, "age", 32).ValueMap(),
wantQuery: "g.addV().property(single, $0, $1).valueMap()",
wantBinds: dsl.Bindings{"$0": "age", "$1": 32},
},
{
input: g.V().Count(),
wantQuery: "g.V().count()",
wantBinds: dsl.Bindings{},
},
{
input: g.V().HasNot("age"),
wantQuery: "g.V().hasNot($0)",
wantBinds: dsl.Bindings{"$0": "age"},
},
{
input: func() *dsl.Traversal {
v := g.V().HasID(1)
u := v.Clone().InE().Drop()
return dsl.Join(v, u)
}(),
wantQuery: "g.V().hasId($0); g.V().hasId($1).inE().drop()",
wantBinds: dsl.Bindings{"$0": 1, "$1": 1},
},
{
input: func() *dsl.Traversal {
v := g.V().HasID(1)
u := v.Clone().InE().Drop()
w := u.Clone()
return dsl.Join(v, u, w)
}(),
wantQuery: "g.V().hasId($0); g.V().hasId($1).inE().drop(); g.V().hasId($2).inE().drop()",
wantBinds: dsl.Bindings{"$0": 1, "$1": 1, "$2": 1},
},
{
input: g.V().OutE("knows").Where(__.InV().Has("name", "a8m")).OutV(),
wantQuery: "g.V().outE($0).where(__.inV().has($1, $2)).outV()",
wantBinds: dsl.Bindings{"$0": "knows", "$1": "name", "$2": "a8m"},
},
{
input: g.V().Has("name", p.Within("a8m", "alex")),
wantQuery: "g.V().has($0, within($1, $2))",
wantBinds: dsl.Bindings{"$0": "name", "$1": "a8m", "$2": "alex"},
},
{
input: g.V().HasID(p.Within(1, 2)),
wantQuery: "g.V().hasId(within($0, $1))",
wantBinds: dsl.Bindings{"$0": 1, "$1": 2},
},
{
input: g.V().HasID(p.Without(1, 2)),
wantQuery: "g.V().hasId(without($0, $1))",
wantBinds: dsl.Bindings{"$0": 1, "$1": 2},
},
{
input: g.V().Order().By("name"),
wantQuery: "g.V().order().by($0)",
wantBinds: dsl.Bindings{"$0": "name"},
},
{
input: g.V().Order().By("name", dsl.Incr),
wantQuery: "g.V().order().by($0, incr)",
wantBinds: dsl.Bindings{"$0": "name"},
},
{
input: g.V().Order().By("name", dsl.Incr).Undo(),
wantQuery: "g.V().order()",
wantBinds: dsl.Bindings{},
},
{
input: g.V().OutE("knows").Where(__.InV().Has("name", "a8m")).Undo(),
wantQuery: "g.V().outE($0)",
wantBinds: dsl.Bindings{"$0": "knows"},
},
{
input: g.V().Has("name").Group().By("name").By("age").Select(dsl.Values),
wantQuery: "g.V().has($0).group().by($1).by($2).select(values)",
wantBinds: dsl.Bindings{"$0": "name", "$1": "name", "$2": "age"},
},
{
input: g.V().Fold().Unfold(),
wantQuery: "g.V().fold().unfold()",
wantBinds: dsl.Bindings{},
},
{
input: g.V().Has("person", "name", "a8m").Count().Coalesce(
__.Is(p.NEQ(0)).Constant("unique constraint failed"),
g.AddV("person").Property("name", "a8m").ValueMap(true),
),
wantQuery: "g.V().has($0, $1, $2).count().coalesce(__.is(neq($3)).constant($4), g.addV($5).property($6, $7).valueMap($8))",
wantBinds: dsl.Bindings{"$0": "person", "$1": "name", "$2": "a8m", "$3": 0, "$4": "unique constraint failed", "$5": "person", "$6": "name", "$7": "a8m", "$8": true},
},
{
input: g.V().Has("age").Property("age", __.Union(__.Values("age"), __.Constant(10)).Sum()).ValueMap(),
wantQuery: "g.V().has($0).property($1, __.union(__.values($2), __.constant($3)).sum()).valueMap()",
wantBinds: dsl.Bindings{"$0": "age", "$1": "age", "$2": "age", "$3": 10},
},
{
input: g.V().Has("age").SideEffect(__.Properties("name").Drop()).ValueMap(),
wantQuery: "g.V().has($0).sideEffect(__.properties($1).drop()).valueMap()",
wantBinds: dsl.Bindings{"$0": "age", "$1": "name"},
},
}
for i, tt := range tests {
tt := tt
t.Run(strconv.Itoa(i), func(t *testing.T) {
query, bindings := tt.input.Query()
require.Equal(t, tt.wantQuery, query)
require.Equal(t, tt.wantBinds, bindings)
})
}
}

View File

@@ -0,0 +1,19 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package g
import "entgo.io/ent/dialect/gremlin/graph/dsl"
// V is the api for calling g.V().
func V(args ...any) *dsl.Traversal { return dsl.NewTraversal().V(args...) }
// E is the api for calling g.E().
func E(args ...any) *dsl.Traversal { return dsl.NewTraversal().E(args...) }
// AddV is the api for calling g.AddV().
func AddV(args ...any) *dsl.Traversal { return dsl.NewTraversal().AddV(args...) }
// AddE is the api for calling g.AddE().
func AddE(args ...any) *dsl.Traversal { return dsl.NewTraversal().AddE(args...) }

View File

@@ -0,0 +1,93 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package p
import (
"entgo.io/ent/dialect/gremlin/graph/dsl"
)
// EQ is the equal predicate.
func EQ(v any) *dsl.Traversal {
return op("eq", v)
}
// NEQ is the not-equal predicate.
func NEQ(v any) *dsl.Traversal {
return op("neq", v)
}
// GT is the greater than predicate.
func GT(v any) *dsl.Traversal {
return op("gt", v)
}
// GTE is the greater than or equal predicate.
func GTE(v any) *dsl.Traversal {
return op("gte", v)
}
// LT is the less than predicate.
func LT(v any) *dsl.Traversal {
return op("lt", v)
}
// LTE is the less than or equal predicate.
func LTE(v any) *dsl.Traversal {
return op("lte", v)
}
// Between is the between/contains predicate.
func Between(v, u any) *dsl.Traversal {
return op("between", v, u)
}
// StartingWith is the prefix test predicate.
func StartingWith(prefix string) *dsl.Traversal {
return op("startingWith", prefix)
}
// EndingWith is the suffix test predicate.
func EndingWith(suffix string) *dsl.Traversal {
return op("endingWith", suffix)
}
// Containing is the sub string test predicate.
func Containing(substr string) *dsl.Traversal {
return op("containing", substr)
}
// NotStartingWith is the negation of StartingWith.
func NotStartingWith(prefix string) *dsl.Traversal {
return op("notStartingWith", prefix)
}
// NotEndingWith is the negation of EndingWith.
func NotEndingWith(suffix string) *dsl.Traversal {
return op("notEndingWith", suffix)
}
// NotContaining is the negation of Containing.
func NotContaining(substr string) *dsl.Traversal {
return op("notContaining", substr)
}
// Within Determines if a value is within the specified list of values.
func Within[T any](args ...T) *dsl.Traversal {
return op("within", args...)
}
// Without determines if a value is not within the specified list of values.
func Without[T any](args ...T) *dsl.Traversal {
return op("without", args...)
}
func op[T any](name string, args ...T) *dsl.Traversal {
t := &dsl.Traversal{}
vs := make([]any, len(args))
for i, arg := range args {
vs[i] = arg
}
return t.Add(dsl.NewFunc(name, vs...))
}

View File

@@ -0,0 +1,426 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package dsl
import (
"errors"
"fmt"
"strings"
)
// Traversal mimics the TinkerPop graph traversal.
type Traversal struct {
// nodes holds the dsl nodes. first element is the reference name
// of the TinkerGraph. defaults to "g".
nodes []Node
errs []error
}
// NewTraversal returns a new default traversal with "g" as a reference name to the Graph.
func NewTraversal() *Traversal {
return &Traversal{nodes: []Node{G}}
}
// Group groups a list of traversals into one. all traversals are assigned into a temporary
// variables named by their index. The last variable functions as a return value of the query.
// Note that, this "temporary hack" is not perfect and may not work in some cases because of
// the limitation of evaluation order.
func Group(trs ...*Traversal) *Traversal {
var (
b = Block{}
names = make(map[*Traversal]Token)
)
for i, tr := range trs {
if _, ok := names[tr]; ok {
continue
}
v := &Var{Name: fmt.Sprintf("t%d", i), Elem: &Traversal{nodes: tr.nodes}}
b.Nodes = append(b.Nodes, v)
names[tr] = Token(v.Name)
}
for _, tr := range trs {
tr.nodes = []Node{names[tr]}
}
b.Nodes = append(b.Nodes, names[trs[len(trs)-1]])
return &Traversal{nodes: []Node{b}}
}
// Join joins a list of traversals with a semicolon separator.
func Join(trs ...*Traversal) *Traversal {
b := Block{}
for _, tr := range trs {
b.Nodes = append(b.Nodes, &Traversal{nodes: tr.nodes})
}
return &Traversal{nodes: []Node{b}}
}
// AddError adds an error to the traversal.
func (t *Traversal) AddError(err error) *Traversal {
t.errs = append(t.errs, err)
return t
}
// Err returns a concatenated error of all errors encountered during
// the query-building, or were added manually by calling AddError.
func (t *Traversal) Err() error {
if len(t.errs) == 0 {
return nil
}
br := strings.Builder{}
for i := range t.errs {
if i > 0 {
br.WriteString("; ")
}
br.WriteString(t.errs[i].Error())
}
return errors.New(br.String())
}
// V step is usually used to start a traversal but it may also be used mid-traversal.
func (t *Traversal) V(args ...any) *Traversal {
t.Add(Dot, NewFunc("V", args...))
return t
}
// OtherV maps the Edge to the incident vertex that was not just traversed from in the path history.
func (t *Traversal) OtherV() *Traversal {
t.Add(Dot, NewFunc("otherV"))
return t
}
// E step is usually used to start a traversal but it may also be used mid-traversal.
func (t *Traversal) E(args ...any) *Traversal {
t.Add(Dot, NewFunc("E", args...))
return t
}
// AddV adds a vertex.
func (t *Traversal) AddV(args ...any) *Traversal {
t.Add(Dot, NewFunc("addV", args...))
return t
}
// AddE adds an edge.
func (t *Traversal) AddE(args ...any) *Traversal {
t.Add(Dot, NewFunc("addE", args...))
return t
}
// Next gets the next n-number of results from the traversal.
func (t *Traversal) Next() *Traversal {
return t.Add(Dot, NewFunc("next"))
}
// Drop removes elements and properties from the graph.
func (t *Traversal) Drop() *Traversal {
return t.Add(Dot, NewFunc("drop"))
}
// Property sets a Property value and related meta properties if supplied,
// if supported by the Graph and if the Element is a VertexProperty.
func (t *Traversal) Property(args ...any) *Traversal {
return t.Add(Dot, NewFunc("property", args...))
}
// Both maps the Vertex to its adjacent vertices given the edge labels.
func (t *Traversal) Both(args ...any) *Traversal {
return t.Add(Dot, NewFunc("both", args...))
}
// BothE maps the Vertex to its incident edges given the edge labels.
func (t *Traversal) BothE(args ...any) *Traversal {
return t.Add(Dot, NewFunc("bothE", args...))
}
// Has filters vertices, edges and vertex properties based on their properties.
// See: http://tinkerpop.apache.org/docs/current/reference/#has-step.
func (t *Traversal) Has(args ...any) *Traversal {
return t.Add(Dot, NewFunc("has", args...))
}
// HasNot filters vertices, edges and vertex properties based on the non-existence of properties.
// See: http://tinkerpop.apache.org/docs/current/reference/#has-step.
func (t *Traversal) HasNot(args ...any) *Traversal {
return t.Add(Dot, NewFunc("hasNot", args...))
}
// HasID filters vertices, edges and vertex properties based on their identifier.
func (t *Traversal) HasID(args ...any) *Traversal {
return t.Add(Dot, NewFunc("hasId", args...))
}
// HasLabel filters vertices, edges and vertex properties based on their label.
func (t *Traversal) HasLabel(args ...any) *Traversal {
return t.Add(Dot, NewFunc("hasLabel", args...))
}
// HasNext returns true if the iteration has more elements.
func (t *Traversal) HasNext() *Traversal {
return t.Add(Dot, NewFunc("hasNext"))
}
// Match maps the Traverser to a Map of bindings as specified by the provided match traversals.
func (t *Traversal) Match(args ...any) *Traversal {
return t.Add(Dot, NewFunc("match", args...))
}
// Choose routes the current traverser to a particular traversal branch option which allows the creation of if-then-else like semantics within a traversal.
func (t *Traversal) Choose(args ...any) *Traversal {
return t.Add(Dot, NewFunc("choose", args...))
}
// Select arbitrary values from the traversal.
func (t *Traversal) Select(args ...any) *Traversal {
return t.Add(Dot, NewFunc("select", args...))
}
// Group organizes objects in the stream into a Map.Calls to group() are typically accompanied with by() modulators which help specify how the grouping should occur.
func (t *Traversal) Group() *Traversal {
return t.Add(Dot, NewFunc("group"))
}
// Values maps the Element to the values of the associated properties given the provide property keys.
func (t *Traversal) Values(args ...string) *Traversal {
return t.Add(Dot, NewFunc("values", sface(args)...))
}
// ValueMap maps the Element to a Map of the property values key'd according to their Property.key().
func (t *Traversal) ValueMap(args ...any) *Traversal {
return t.Add(Dot, NewFunc("valueMap", args...))
}
// Properties maps the Element to its associated properties given the provide property keys.
func (t *Traversal) Properties(args ...any) *Traversal {
return t.Add(Dot, NewFunc("properties", args...))
}
// Range filters the objects in the traversal by the number of them to pass through the stream.
func (t *Traversal) Range(args ...any) *Traversal {
return t.Add(Dot, NewFunc("range", args...))
}
// Limit filters the objects in the traversal by the number of them to pass through the stream, where only the first n objects are allowed as defined by the limit argument.
func (t *Traversal) Limit(args ...any) *Traversal {
return t.Add(Dot, NewFunc("limit", args...))
}
// ID maps the Element to its Element.id().
func (t *Traversal) ID() *Traversal {
return t.Add(Dot, NewFunc("id"))
}
// Label maps the Element to its Element.label().
func (t *Traversal) Label() *Traversal {
return t.Add(Dot, NewFunc("label"))
}
// From provides from()-modulation to respective steps.
func (t *Traversal) From(args ...any) *Traversal {
return t.Add(Dot, NewFunc("from", args...))
}
// To used as a modifier to addE(String) this method specifies the traversal to use for selecting the incoming vertex of the newly added Edge.
func (t *Traversal) To(args ...any) *Traversal {
return t.Add(Dot, NewFunc("to", args...))
}
// As provides a label to the step that can be accessed later in the traversal by other steps.
func (t *Traversal) As(args ...any) *Traversal {
return t.Add(Dot, NewFunc("as", args...))
}
// Or ensures that at least one of the provided traversals yield a result.
func (t *Traversal) Or(args ...any) *Traversal {
return t.Add(Dot, NewFunc("or", args...))
}
// And ensures that all of the provided traversals yield a result.
func (t *Traversal) And(args ...any) *Traversal {
return t.Add(Dot, NewFunc("and", args...))
}
// Is filters the E object if it is not P.eq(V) to the provided value.
func (t *Traversal) Is(args ...any) *Traversal {
return t.Add(Dot, NewFunc("is", args...))
}
// Not removes objects from the traversal stream when the traversal provided as an argument does not return any objects.
func (t *Traversal) Not(args ...any) *Traversal {
return t.Add(Dot, NewFunc("not", args...))
}
// In maps the Vertex to its incoming adjacent vertices given the edge labels.
func (t *Traversal) In(args ...any) *Traversal {
return t.Add(Dot, NewFunc("in", args...))
}
// Where filters the current object based on the object itself or the path history.
func (t *Traversal) Where(args ...any) *Traversal {
return t.Add(Dot, NewFunc("where", args...))
}
// Out maps the Vertex to its outgoing adjacent vertices given the edge labels.
func (t *Traversal) Out(args ...any) *Traversal {
return t.Add(Dot, NewFunc("out", args...))
}
// OutE maps the Vertex to its outgoing incident edges given the edge labels.
func (t *Traversal) OutE(args ...any) *Traversal {
return t.Add(Dot, NewFunc("outE", args...))
}
// InE maps the Vertex to its incoming incident edges given the edge labels.
func (t *Traversal) InE(args ...any) *Traversal {
return t.Add(Dot, NewFunc("inE", args...))
}
// OutV maps the Edge to its outgoing/tail incident Vertex.
func (t *Traversal) OutV(args ...any) *Traversal {
return t.Add(Dot, NewFunc("outV", args...))
}
// InV maps the Edge to its incoming/head incident Vertex.
func (t *Traversal) InV(args ...any) *Traversal {
return t.Add(Dot, NewFunc("inV", args...))
}
// ToList puts all the results into a Groovy list.
func (t *Traversal) ToList() *Traversal {
return t.Add(Dot, NewFunc("toList"))
}
// Iterate iterates the traversal presumably for the generation of side-effects.
func (t *Traversal) Iterate() *Traversal {
return t.Add(Dot, NewFunc("iterate"))
}
// Count maps the traversal stream to its reduction as a sum of the Traverser.bulk() values
// (i.e. count the number of traversers up to this point).
func (t *Traversal) Count(args ...any) *Traversal {
return t.Add(Dot, NewFunc("count", args...))
}
// Order all the objects in the traversal up to this point and then emit them one-by-one in their ordered sequence.
func (t *Traversal) Order(args ...any) *Traversal {
return t.Add(Dot, NewFunc("order", args...))
}
// By can be applied to a number of different step to alter their behaviors.
// This form is essentially an identity() modulation.
func (t *Traversal) By(args ...any) *Traversal {
return t.Add(Dot, NewFunc("by", args...))
}
// Fold rolls up objects in the stream into an aggregate list..
func (t *Traversal) Fold() *Traversal {
return t.Add(Dot, NewFunc("fold"))
}
// Unfold unrolls a Iterator, Iterable or Map into a linear form or simply emits the object if it is not one of those types.
func (t *Traversal) Unfold() *Traversal {
return t.Add(Dot, NewFunc("unfold"))
}
// Sum maps the traversal stream to its reduction as a sum of the Traverser.get() values multiplied by their Traverser.bulk().
func (t *Traversal) Sum(args ...any) *Traversal {
return t.Add(Dot, NewFunc("sum", args...))
}
// Mean determines the mean value in the stream.
func (t *Traversal) Mean(args ...any) *Traversal {
return t.Add(Dot, NewFunc("mean", args...))
}
// Min determines the smallest value in the stream.
func (t *Traversal) Min(args ...any) *Traversal {
return t.Add(Dot, NewFunc("min", args...))
}
// Max determines the greatest value in the stream.
func (t *Traversal) Max(args ...any) *Traversal {
return t.Add(Dot, NewFunc("max", args...))
}
// Coalesce evaluates the provided traversals and returns the result of the first traversal to emit at least one object.
func (t *Traversal) Coalesce(args ...any) *Traversal {
return t.Add(Dot, NewFunc("coalesce", args...))
}
// Dedup removes all duplicates in the traversal stream up to this point.
func (t *Traversal) Dedup(args ...any) *Traversal {
return t.Add(Dot, NewFunc("dedup", args...))
}
// Constant maps any object to a fixed E value.
func (t *Traversal) Constant(args ...any) *Traversal {
return t.Add(Dot, NewFunc("constant", args...))
}
// Union merges the results of an arbitrary number of traversals.
func (t *Traversal) Union(args ...any) *Traversal {
return t.Add(Dot, NewFunc("union", args...))
}
// SideEffect allows the traverser to proceed unchanged, but yield some computational
// sideEffect in the process.
func (t *Traversal) SideEffect(args ...any) *Traversal {
return t.Add(Dot, NewFunc("sideEffect", args...))
}
// Each is a Groovy each-loop function.
func Each(v any, cb func(it *Traversal) *Traversal) *Traversal {
t := &Traversal{}
switch v := v.(type) {
case *Traversal:
t.Add(&Var{Elem: v})
case []any:
t.Add(NewList(v...))
default:
t.Add(Token("undefined"))
}
t.Add(Dot, Token("each"), Token(" { "))
t.Add(cb(&Traversal{nodes: []Node{Token("it")}}).nodes...)
t.Add(Token(" }"))
return t
}
// Add is the public API for adding new nodes to the traversal by its sub packages.
func (t *Traversal) Add(n ...Node) *Traversal {
t.nodes = append(t.nodes, n...)
return t
}
// Query returns the query-representation and its binding of this traversal object.
func (t *Traversal) Query() (string, Bindings) {
var (
names []any
query strings.Builder
bindings = Bindings{}
)
for _, n := range t.nodes {
code, args := n.Code()
query.WriteString(code)
for _, arg := range args {
names = append(names, bindings.Add(arg))
}
}
return fmt.Sprintf(query.String(), names...), bindings
}
// Clone creates a deep copy of an existing traversal.
func (t *Traversal) Clone() *Traversal {
if t == nil {
return nil
}
return &Traversal{nodes: append(make([]Node, 0, len(t.nodes)), t.nodes...)}
}
// Undo reverts the last-step of the traversal.
func (t *Traversal) Undo() *Traversal {
if n := len(t.nodes); n > 2 {
t.nodes = t.nodes[:n-2]
}
return t
}

View File

@@ -0,0 +1,94 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"fmt"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
)
type (
// An Edge between two vertices.
Edge struct {
Element
OutV, InV Vertex
}
// graphson edge repr.
edge struct {
Element
OutV any `json:"outV"`
OutVLabel string `json:"outVLabel"`
InV any `json:"inV"`
InVLabel string `json:"inVLabel"`
}
)
// NewEdge create a new graph edge.
func NewEdge(id any, label string, outV, inV Vertex) Edge {
return Edge{
Element: NewElement(id, label),
OutV: outV,
InV: inV,
}
}
// String implements fmt.Stringer interface.
func (e Edge) String() string {
return fmt.Sprintf("e[%v][%v-%s->%v]", e.ID, e.OutV.ID, e.Label, e.InV.ID)
}
// MarshalGraphson implements graphson.Marshaler interface.
func (e Edge) MarshalGraphson() ([]byte, error) {
return graphson.Marshal(edge{
Element: e.Element,
OutV: e.OutV.ID,
OutVLabel: e.OutV.Label,
InV: e.InV.ID,
InVLabel: e.InV.Label,
})
}
// UnmarshalGraphson implements graphson.Unmarshaler interface.
func (e *Edge) UnmarshalGraphson(data []byte) error {
var edge edge
if err := graphson.Unmarshal(data, &edge); err != nil {
return fmt.Errorf("unmarshalling edge: %w", err)
}
*e = NewEdge(
edge.ID, edge.Label,
NewVertex(edge.OutV, edge.OutVLabel),
NewVertex(edge.InV, edge.InVLabel),
)
return nil
}
// GraphsonType implements graphson.Typer interface.
func (edge) GraphsonType() graphson.Type {
return "g:Edge"
}
// Property denotes a key/value pair associated with an edge.
type Property struct {
Key string `json:"key"`
Value any `json:"value"`
}
// NewProperty create a new graph edge property.
func NewProperty(key string, value any) Property {
return Property{key, value}
}
// GraphsonType implements graphson.Typer interface.
func (Property) GraphsonType() graphson.Type {
return "g:Property"
}
// String implements fmt.Stringer interface.
func (p Property) String() string {
return fmt.Sprintf("p[%s->%v]", p.Key, p.Value)
}

View File

@@ -0,0 +1,108 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"fmt"
"testing"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEdgeString(t *testing.T) {
e := NewEdge(
13, "develops",
NewVertex(1, ""),
NewVertex(10, ""),
)
assert.Equal(t, "e[13][1-develops->10]", fmt.Sprint(e))
}
func TestEdgeEncoding(t *testing.T) {
t.Parallel()
e := NewEdge(13, "develops",
NewVertex(1, "person"),
NewVertex(10, "software"),
)
got, err := graphson.MarshalToString(e)
require.NoError(t, err)
want := `{
"@type" : "g:Edge",
"@value" : {
"id" : {
"@type" : "g:Int64",
"@value" : 13
},
"label" : "develops",
"inVLabel" : "software",
"outVLabel" : "person",
"inV" : {
"@type" : "g:Int64",
"@value" : 10
},
"outV" : {
"@type" : "g:Int64",
"@value" : 1
}
}
}`
assert.JSONEq(t, want, got)
e = Edge{}
err = graphson.UnmarshalFromString(got, &e)
require.NoError(t, err)
assert.Equal(t, NewElement(int64(13), "develops"), e.Element)
assert.Equal(t, NewVertex(int64(1), "person"), e.OutV)
assert.Equal(t, NewVertex(int64(10), "software"), e.InV)
}
func TestPropertyEncoding(t *testing.T) {
t.Parallel()
props := []Property{
NewProperty("from", int32(2017)),
NewProperty("to", int32(2019)),
}
got, err := graphson.MarshalToString(props)
require.NoError(t, err)
want := `{
"@type" : "g:List",
"@value" : [
{
"@type" : "g:Property",
"@value" : {
"key" : "from",
"value" : {
"@type" : "g:Int32",
"@value" : 2017
}
}
},
{
"@type" : "g:Property",
"@value" : {
"key" : "to",
"value" : {
"@type" : "g:Int32",
"@value" : 2019
}
}
}
]
}`
assert.JSONEq(t, want, got)
}
func TestPropertyString(t *testing.T) {
p := NewProperty("since", 2019)
assert.Equal(t, "p[since->2019]", fmt.Sprint(p))
}

View File

@@ -0,0 +1,16 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
// Element defines a base struct for graph elements.
type Element struct {
ID any `json:"id"`
Label string `json:"label"`
}
// NewElement create a new graph element.
func NewElement(id any, label string) Element {
return Element{id, label}
}

View File

@@ -0,0 +1,57 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"errors"
"fmt"
"reflect"
"github.com/mitchellh/mapstructure"
)
// ValueMap models a .valueMap() gremlin response.
type ValueMap []map[string]any
// Decode decodes a value map into v.
func (m ValueMap) Decode(v any) error {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr {
return errors.New("cannot unmarshal into a non pointer")
}
if rv.IsNil() {
return errors.New("cannot unmarshal into a nil pointer")
}
if rv.Elem().Kind() != reflect.Slice {
v = &[]any{v}
}
return m.decode(v)
}
func (m ValueMap) decode(v any) error {
cfg := mapstructure.DecoderConfig{
DecodeHook: func(f, t reflect.Kind, data any) (any, error) {
if f == reflect.Slice && t != reflect.Slice {
rv := reflect.ValueOf(data)
if rv.Len() == 1 {
data = rv.Index(0).Interface()
}
}
return data, nil
},
Result: v,
TagName: "json",
}
dec, err := mapstructure.NewDecoder(&cfg)
if err != nil {
return fmt.Errorf("creating structure decoder: %w", err)
}
if err := dec.Decode(m); err != nil {
return fmt.Errorf("decoding value map: %w", err)
}
return nil
}

View File

@@ -0,0 +1,74 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValueMapDecodeOne(t *testing.T) {
vm := ValueMap{map[string]any{
"id": int64(1),
"label": "person",
"name": []any{"marko"},
"age": []any{int32(29)},
}}
var ent struct {
ID uint64 `json:"id"`
Label string `json:"label"`
Name string `json:"name"`
Age uint8 `json:"age"`
}
err := vm.Decode(&ent)
require.NoError(t, err)
assert.Equal(t, uint64(1), ent.ID)
assert.Equal(t, "person", ent.Label)
assert.Equal(t, "marko", ent.Name)
assert.Equal(t, uint8(29), ent.Age)
}
func TestValueMapDecodeMany(t *testing.T) {
vm := ValueMap{
map[string]any{
"id": int64(1),
"label": "person",
"name": []any{"chico"},
},
map[string]any{
"id": int64(2),
"label": "person",
"name": []any{"dico"},
},
}
ents := []struct {
ID int `json:"id"`
Label string `json:"label"`
Name string `json:"name"`
}{}
err := vm.Decode(&ents)
require.NoError(t, err)
require.Len(t, ents, 2)
assert.Equal(t, 1, ents[0].ID)
assert.Equal(t, "person", ents[0].Label)
assert.Equal(t, "chico", ents[0].Name)
assert.Equal(t, 2, ents[1].ID)
assert.Equal(t, "person", ents[1].Label)
assert.Equal(t, "dico", ents[1].Name)
}
func TestValueMapDecodeBadInput(t *testing.T) {
type s struct{ Name string }
err := ValueMap{}.Decode(s{})
assert.Error(t, err)
err = ValueMap{}.Decode((*s)(nil))
assert.Error(t, err)
}

View File

@@ -0,0 +1,62 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"fmt"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
)
// Vertex represents a graph vertex.
type Vertex struct {
Element
}
// NewVertex create a new graph vertex.
func NewVertex(id any, label string) Vertex {
if label == "" {
label = "vertex"
}
return Vertex{
Element: NewElement(id, label),
}
}
// GraphsonType implements graphson.Typer interface.
func (Vertex) GraphsonType() graphson.Type {
return "g:Vertex"
}
// String implements fmt.Stringer interface.
func (v Vertex) String() string {
return fmt.Sprintf("v[%v]", v.ID)
}
// VertexProperty denotes a key/value pair associated with a vertex.
type VertexProperty struct {
ID any `json:"id"`
Key string `json:"label"`
Value any `json:"value"`
}
// NewVertexProperty create a new graph vertex property.
func NewVertexProperty(id any, key string, value any) VertexProperty {
return VertexProperty{
ID: id,
Key: key,
Value: value,
}
}
// GraphsonType implements graphson.Typer interface.
func (VertexProperty) GraphsonType() graphson.Type {
return "g:VertexProperty"
}
// String implements fmt.Stringer interface.
func (vp VertexProperty) String() string {
return fmt.Sprintf("vp[%s->%v]", vp.Key, vp.Value)
}

View File

@@ -0,0 +1,86 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package graph
import (
"fmt"
"testing"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVertexCreation(t *testing.T) {
v := NewVertex(45, "person")
assert.Equal(t, 45, v.ID)
assert.Equal(t, "person", v.Label)
v = NewVertex(46, "")
assert.Equal(t, "vertex", v.Label)
}
func TestVertexString(t *testing.T) {
v := NewVertex(42, "")
assert.Equal(t, "v[42]", fmt.Sprint(v))
}
func TestVertexEncoding(t *testing.T) {
t.Parallel()
v := NewVertex(1, "user")
got, err := graphson.MarshalToString(v)
require.NoError(t, err)
want := `{
"@type" : "g:Vertex",
"@value" : {
"id" : {
"@type" : "g:Int64",
"@value" : 1
},
"label" : "user"
}
}`
assert.JSONEq(t, want, got)
v = Vertex{}
err = graphson.UnmarshalFromString(got, &v)
require.NoError(t, err)
assert.Equal(t, int64(1), v.ID)
assert.Equal(t, "user", v.Label)
}
func TestVertexPropertyEncoding(t *testing.T) {
t.Parallel()
vp := NewVertexProperty("46ab60c2-918c-4cc4-a13b-350510e8908a", "name", "alex")
got, err := graphson.MarshalToString(vp)
require.NoError(t, err)
want := `{
"@type" : "g:VertexProperty",
"@value" : {
"id" : "46ab60c2-918c-4cc4-a13b-350510e8908a",
"label": "name",
"value": "alex"
}
}`
assert.JSONEq(t, want, got)
vp = VertexProperty{}
err = graphson.UnmarshalFromString(got, &vp)
require.NoError(t, err)
assert.Equal(t, "46ab60c2-918c-4cc4-a13b-350510e8908a", vp.ID)
assert.Equal(t, "name", vp.Key)
assert.Equal(t, "alex", vp.Value)
}
func TestVertexPropertyString(t *testing.T) {
vp := NewVertexProperty(55, "country", "israel")
assert.Equal(t, "vp[country->israel]", fmt.Sprint(vp))
}

85
dialect/gremlin/http.go Normal file
View File

@@ -0,0 +1,85 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
jsoniter "github.com/json-iterator/go"
)
type httpTransport struct {
client *http.Client
url string
}
// NewHTTPTransport returns a new http transport.
func NewHTTPTransport(urlStr string, client *http.Client) (RoundTripper, error) {
u, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("gremlin/http: parsing url: %w", err)
}
if client == nil {
client = http.DefaultClient
}
return &httpTransport{client, u.String()}, nil
}
// RoundTrip implements RouterTripper interface.
func (t *httpTransport) RoundTrip(ctx context.Context, req *Request) (*Response, error) {
if req.Operation != OpsEval {
return nil, fmt.Errorf("gremlin/http: unsupported operation: %q", req.Operation)
}
if _, ok := req.Arguments[ArgsGremlin]; !ok {
return nil, errors.New("gremlin/http: missing query expression")
}
pr, pw := io.Pipe()
defer pr.Close()
go func() {
err := jsoniter.NewEncoder(pw).Encode(req.Arguments)
if err != nil {
err = fmt.Errorf("gremlin/http: encoding request: %w", err)
}
_ = pw.CloseWithError(err)
}()
var br io.Reader
{
req, err := http.NewRequest(http.MethodPost, t.url, pr)
if err != nil {
return nil, fmt.Errorf("gremlin/http: creating http request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
rsp, err := t.client.Do(req.WithContext(ctx))
if err != nil {
return nil, fmt.Errorf("gremlin/http: posting http request: %w", err)
}
defer rsp.Body.Close()
if rsp.StatusCode < http.StatusOK || rsp.StatusCode > http.StatusPartialContent {
body, _ := io.ReadAll(rsp.Body)
return nil, fmt.Errorf("gremlin/http: status=%q, body=%q", rsp.Status, body)
}
if rsp.ContentLength > MaxResponseSize {
return nil, errors.New("gremlin/http: context length exceeds limit")
}
br = rsp.Body
}
var rsp Response
if err := graphson.NewDecoder(io.LimitReader(br, MaxResponseSize)).Decode(&rsp); err != nil {
return nil, fmt.Errorf("gremlin/http: decoding response: %w", err)
}
return &rsp, nil
}

View File

@@ -0,0 +1,130 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package gremlin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPTransportRoundTripper(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, r.Header.Get("Content-Type"), "application/json")
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.JSONEq(t, `{"gremlin": "g.V(1)", "language": "gremlin-groovy"}`, string(got))
_, err = io.WriteString(w, `{
"requestId": "f679127f-8701-425c-af55-049a44720db6",
"result": {
"data": {
"@type": "g:List",
"@value": [
{
"@type": "g:Vertex",
"@value": {
"id": {
"@type": "g:Int64",
"@value": 1
},
"label": "person"
}
}
]
},
"meta": {
"@type": "g:Map",
"@value": []
}
},
"status": {
"attributes": {
"@type": "g:Map",
"@value": []
},
"code": 200,
"message": ""
}
}`)
require.NoError(t, err)
}))
defer srv.Close()
transport, err := NewHTTPTransport(srv.URL, nil)
require.NoError(t, err)
rsp, err := transport.RoundTrip(context.Background(), NewEvalRequest("g.V(1)"))
require.NoError(t, err)
assert.Equal(t, "f679127f-8701-425c-af55-049a44720db6", rsp.RequestID)
assert.Equal(t, 200, rsp.Status.Code)
assert.Empty(t, rsp.Status.Message)
v := jsoniter.Get(rsp.Result.Data, graphson.ValueKey, 0, graphson.ValueKey)
require.NoError(t, v.LastError())
assert.Equal(t, 1, v.Get("id", graphson.ValueKey).ToInt())
assert.Equal(t, "person", v.Get("label").ToString())
}
func TestNewHTTPTransportBadURL(t *testing.T) {
transport, err := NewHTTPTransport(":", nil)
assert.Nil(t, transport)
assert.Error(t, err)
}
func TestHTTPTransportBadRequest(t *testing.T) {
transport, err := NewHTTPTransport("example.com", nil)
require.NoError(t, err)
req := NewEvalRequest("g.V()")
req.Operation = ""
rsp, err := transport.RoundTrip(context.Background(), req)
assert.EqualError(t, err, `gremlin/http: unsupported operation: ""`)
assert.Nil(t, rsp)
req = NewEvalRequest("g.V()")
delete(req.Arguments, ArgsGremlin)
rsp, err = transport.RoundTrip(context.Background(), req)
assert.EqualError(t, err, "gremlin/http: missing query expression")
assert.Nil(t, rsp)
}
func TestHTTPTransportBadResponseStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
transport, err := NewHTTPTransport(srv.URL, nil)
require.NoError(t, err)
_, err = transport.RoundTrip(context.Background(), NewEvalRequest("g.E()."))
require.Error(t, err)
assert.Contains(t, err.Error(), http.StatusText(http.StatusInternalServerError))
}
func TestHTTPTransportBadResponseBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := io.WriteString(w, "{{{")
require.NoError(t, err)
}))
defer srv.Close()
transport, err := NewHTTPTransport(srv.URL, nil)
require.NoError(t, err)
_, err = transport.RoundTrip(context.Background(), NewEvalRequest("g.E()."))
require.Error(t, err)
assert.Contains(t, err.Error(), "decoding response")
}

View File

@@ -0,0 +1,315 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ws
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"entgo.io/ent/dialect/gremlin"
"entgo.io/ent/dialect/gremlin/encoding"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
)
const (
// Time allowed to write a message to the peer.
writeWait = 5 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
)
type (
// A Dialer contains options for connecting to Gremlin server.
Dialer struct {
// Underlying websocket dialer.
websocket.Dialer
// Gremlin server basic auth credentials.
user, pass string
}
// Conn performs operations on a gremlin server.
Conn struct {
// Underlying websocket connection.
conn *websocket.Conn
// Credentials for basic authentication.
user, pass string
// Goroutine tracking.
ctx context.Context
grp *errgroup.Group
// Channel of outbound requests.
send chan io.Reader
// Map of in flight requests.
inflight sync.Map
}
// inflight tracks request state.
inflight struct {
// partially received data
frags []graphson.RawMessage
// response channel
result chan<- result
}
// represents an execution result.
result struct {
rsp *gremlin.Response
err error
}
)
var (
// DefaultDialer is a dialer with all fields set to the default values.
DefaultDialer = &Dialer{
Dialer: websocket.Dialer{
Proxy: http.ProxyFromEnvironment,
HandshakeTimeout: 5 * time.Second,
WriteBufferSize: 8192,
ReadBufferSize: 8192,
},
}
// ErrConnClosed is returned by the Conn's Execute method when
// the underlying gremlin server connection is closed.
ErrConnClosed = errors.New("gremlin: server connection closed")
// ErrDuplicateRequest is returned by the Conns Execute method on
// request identifier key collision.
ErrDuplicateRequest = errors.New("gremlin: duplicate request")
)
// Dial creates a new connection by calling DialContext with a background context.
func (d *Dialer) Dial(uri string) (*Conn, error) {
return d.DialContext(context.Background(), uri)
}
// DialContext creates a new Gremlin connection.
func (d *Dialer) DialContext(ctx context.Context, uri string) (*Conn, error) {
c, rsp, err := d.Dialer.DialContext(ctx, uri, nil)
if err != nil {
return nil, fmt.Errorf("gremlin: dialing uri %s: %w", uri, err)
}
defer rsp.Body.Close()
conn := &Conn{
conn: c,
user: d.user,
pass: d.pass,
send: make(chan io.Reader),
}
conn.grp, conn.ctx = errgroup.WithContext(context.Background())
conn.grp.Go(conn.sender)
conn.grp.Go(conn.receiver)
return conn, nil
}
// Execute executes a request against a Gremlin server.
func (c *Conn) Execute(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) {
// buffered result channel prevents receiver block on context cancellation
result := make(chan result, 1)
// request id must be unique across inflight request
if _, loaded := c.inflight.LoadOrStore(req.RequestID, &inflight{result: result}); loaded {
return nil, ErrDuplicateRequest
}
pr, pw := io.Pipe()
defer pr.Close()
// stream graphson encoding into request
c.grp.Go(func() error {
err := graphson.NewEncoder(pw).Encode(req)
if err != nil {
err = fmt.Errorf("encoding request: %w", err)
}
pw.CloseWithError(err)
return err
})
// local copy for single write
send := c.send
for {
select {
case <-c.ctx.Done():
c.inflight.Delete(req.RequestID)
return nil, ErrConnClosed
case <-ctx.Done():
c.inflight.Delete(req.RequestID)
return nil, ctx.Err()
case send <- pr:
send = nil
case result := <-result:
return result.rsp, result.err
}
}
}
// Close connection with a Gremlin server.
func (c *Conn) Close() error {
c.grp.Go(func() error { return ErrConnClosed })
_ = c.grp.Wait()
return nil
}
func (c *Conn) sender() error {
pinger := time.NewTicker(pingPeriod)
defer pinger.Stop()
// closing connection terminates receiver
defer c.conn.Close()
for {
select {
case r := <-c.send:
// ensure write completes within a window
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
// fetch next message writer
w, err := c.conn.NextWriter(websocket.BinaryMessage)
if err != nil {
return fmt.Errorf("getting message writer: %w", err)
}
// write mime header
if _, err := w.Write(encoding.GraphSON3Mime); err != nil {
return fmt.Errorf("writing mime header: %w", err)
}
// write request body
if _, err := io.Copy(w, r); err != nil {
return fmt.Errorf("writing request: %w", err)
}
// finish message write
if err := w.Close(); err != nil {
return fmt.Errorf("closing message writer: %w", err)
}
case <-c.ctx.Done():
// connection closing
return c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
time.Time{},
)
case <-pinger.C:
// periodic connection keepalive
if err := c.conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil {
return fmt.Errorf("writing ping message: %w", err)
}
}
}
}
func (c *Conn) receiver() error {
// handle keepalive responses
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
// complete all in flight requests on termination
defer c.inflight.Range(func(id, ifr any) bool {
ifr.(*inflight).result <- result{err: ErrConnClosed}
c.inflight.Delete(id)
return true
})
for {
// rely on sender connection close during termination
_, r, err := c.conn.NextReader()
if err != nil {
return fmt.Errorf("writing ping message: %w", err)
}
// decode received response
var rsp gremlin.Response
if err := graphson.NewDecoder(r).Decode(&rsp); err != nil {
return fmt.Errorf("reading response: %w", err)
}
ifr, ok := c.inflight.Load(rsp.RequestID)
if !ok {
// context cancellation aborts inflight requests
continue
}
// handle incoming response
if done := c.receive(ifr.(*inflight), &rsp); done {
// stop tracking finished requests
c.inflight.Delete(rsp.RequestID)
}
}
}
func (c *Conn) receive(ifr *inflight, rsp *gremlin.Response) bool {
result := result{rsp: rsp}
switch rsp.Status.Code {
case gremlin.StatusSuccess:
// quickly handle non fragmented responses
if ifr.frags == nil {
break
}
// handle fragment
fallthrough
case gremlin.StatusPartialContent:
// append received fragment
var frag []graphson.RawMessage
if err := graphson.Unmarshal(rsp.Result.Data, &frag); err != nil {
result.err = fmt.Errorf("decoding response fragment: %w", err)
break
}
ifr.frags = append(ifr.frags, frag...)
// partial response requires additional fragments
if rsp.Status.Code == gremlin.StatusPartialContent {
return false
}
// reassemble fragmented response
if rsp.Result.Data, result.err = graphson.Marshal(ifr.frags); result.err != nil {
result.err = fmt.Errorf("assembling fragmented response: %w", result.err)
}
case gremlin.StatusAuthenticate:
// receiver should never block
c.grp.Go(func() error {
var buf bytes.Buffer
if err := graphson.NewEncoder(&buf).Encode(
gremlin.NewAuthRequest(rsp.RequestID, c.user, c.pass),
); err != nil {
return fmt.Errorf("encoding auth request: %w", err)
}
select {
case c.send <- &buf:
case <-c.ctx.Done():
}
return c.ctx.Err()
})
return false
}
ifr.result <- result
return true
}

View File

@@ -0,0 +1,390 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ws
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"entgo.io/ent/dialect/gremlin"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type conn struct{ *websocket.Conn }
func (c conn) ReadRequest() (*gremlin.Request, error) {
_, data, err := c.ReadMessage()
if err != nil {
return nil, err
}
var req gremlin.Request
if err := graphson.Unmarshal(data[data[0]+1:], &req); err != nil {
return nil, err
}
return &req, nil
}
func (c conn) WriteResponse(rsp *gremlin.Response) error {
data, err := graphson.Marshal(rsp)
if err != nil {
return err
}
return c.WriteMessage(websocket.BinaryMessage, data)
}
func serve(handler func(conn)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024}
c, _ := upgrader.Upgrade(w, r, nil)
defer c.Close()
handler(conn{c})
for {
_, _, err := c.ReadMessage()
if err != nil {
break
}
}
}))
}
func TestConnectClosure(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
defer wg.Wait()
srv := serve(func(conn conn) {
defer wg.Done()
_, _, err := conn.ReadMessage()
assert.True(t, websocket.IsCloseError(err, websocket.CloseNormalClosure))
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
err = conn.Close()
assert.NoError(t, err)
_, err = conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()"))
assert.EqualError(t, err, ErrConnClosed.Error())
}
func TestSimpleQuery(t *testing.T) {
srv := serve(func(conn conn) {
typ, data, err := conn.ReadMessage()
require.NoError(t, err)
assert.Equal(t, websocket.BinaryMessage, typ)
var req gremlin.Request
err = graphson.Unmarshal(data[data[0]+1:], &req)
require.NoError(t, err)
assert.Equal(t, "g.V()", req.Arguments["gremlin"])
rsp := gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusNoContent
err = conn.WriteResponse(&rsp)
require.NoError(t, err)
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer assert.Condition(t, func() bool { return assert.NoError(t, conn.Close()) })
rsp, err := conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()"))
assert.NoError(t, err)
require.NotNil(t, rsp)
assert.Equal(t, gremlin.StatusNoContent, rsp.Status.Code)
}
func TestDuplicateRequest(t *testing.T) {
// skip until flakiness will be fixed.
t.SkipNow()
srv := serve(func(conn conn) {
req, err := conn.ReadRequest()
require.NoError(t, err)
rsp := gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusNoContent
err = conn.WriteResponse(&rsp)
require.NoError(t, err)
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
var errors [2]error
req := gremlin.NewEvalRequest("g.V()")
var wg sync.WaitGroup
wg.Add(len(errors))
for i := range errors {
go func(i int) {
_, errors[i] = conn.Execute(context.Background(), req)
wg.Done()
}(i)
}
wg.Wait()
err = errors[0]
if err == nil {
err = errors[1]
}
assert.EqualError(t, err, ErrDuplicateRequest.Error())
}
func TestConnectCancellation(t *testing.T) {
srv := serve(func(conn) {})
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
conn, err := DefaultDialer.DialContext(ctx, "ws://"+srv.Listener.Addr().String())
assert.Error(t, err)
assert.Nil(t, conn)
}
func TestQueryCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
srv := serve(func(conn conn) {
if _, _, err := conn.ReadMessage(); err == nil {
cancel()
}
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
_, err = conn.Execute(ctx, gremlin.NewEvalRequest("g.E()"))
assert.EqualError(t, err, context.Canceled.Error())
}
func TestBadResponse(t *testing.T) {
tests := []struct {
name string
mangle func(*gremlin.Response) *gremlin.Response
}{
{
name: "NoStatus",
mangle: func(rsp *gremlin.Response) *gremlin.Response {
return rsp
},
},
{
name: "Malformed",
mangle: func(rsp *gremlin.Response) *gremlin.Response {
rsp.Status.Code = gremlin.StatusMalformedRequest
rsp.Status.Message = "bad request"
return rsp
},
},
{
name: "Unknown",
mangle: func(rsp *gremlin.Response) *gremlin.Response {
rsp.Status.Code = 424242
return rsp
},
},
}
srv := serve(func(conn conn) {
for {
req, err := conn.ReadRequest()
if err != nil {
break
}
idx, err := strconv.ParseInt(req.Arguments["gremlin"].(string), 10, 0)
require.NoError(t, err)
err = conn.WriteResponse(tests[idx].mangle(&gremlin.Response{RequestID: req.RequestID}))
require.NoError(t, err)
}
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
var wg sync.WaitGroup
wg.Add(len(tests))
ctx := context.Background()
for i, tc := range tests {
i, tc := i, tc
t.Run(tc.name, func(t *testing.T) {
defer wg.Done()
rsp, err := conn.Execute(ctx, gremlin.NewEvalRequest(strconv.FormatInt(int64(i), 10)))
assert.NoError(t, err)
assert.True(t, rsp.IsErr())
})
}
wg.Wait()
}
func TestServerHangup(t *testing.T) {
// skip until flakiness will be fixed.
t.SkipNow()
srv := serve(func(conn conn) { _ = conn.Close() })
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
_, err = conn.Execute(context.Background(), gremlin.NewEvalRequest("g.V()"))
assert.EqualError(t, err, ErrConnClosed.Error())
assert.Error(t, conn.ctx.Err())
}
func TestCanceledLongRequest(t *testing.T) {
// skip until flakiness will be fixed.
t.SkipNow()
ctx, cancel := context.WithCancel(context.Background())
srv := serve(func(conn conn) {
var responses [3]*gremlin.Response
for i := 0; i < len(responses); i++ {
req, err := conn.ReadRequest()
require.NoError(t, err)
rsp := gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusSuccess
rsp.Result.Data = graphson.RawMessage(`"ok"`)
responses[i] = &rsp
}
cancel()
responses[0], responses[2] = responses[2], responses[0]
for i := 0; i < len(responses); i++ {
err := conn.WriteResponse(responses[i])
require.NoError(t, err)
}
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
var wg sync.WaitGroup
wg.Add(3)
defer wg.Wait()
for i := 0; i < 3; i++ {
go func(ctx context.Context, idx int) {
defer wg.Done()
rsp, err := conn.Execute(ctx, gremlin.NewEvalRequest("g.V()"))
if idx > 0 {
assert.NoError(t, err)
assert.EqualValues(t, []byte(`"ok"`), rsp.Result.Data)
} else {
assert.EqualError(t, err, context.Canceled.Error())
}
}(ctx, i)
ctx = context.Background()
}
}
func TestPartialResponse(t *testing.T) {
type kv struct {
Key string
Value int
}
kvs := []kv{
{"one", 1},
{"two", 2},
{"three", 3},
}
srv := serve(func(conn conn) {
req, err := conn.ReadRequest()
require.NoError(t, err)
for i := range kvs {
data, err := graphson.Marshal([]kv{kvs[i]})
require.NoError(t, err)
rsp := gremlin.Response{RequestID: req.RequestID}
rsp.Result.Data = graphson.RawMessage(data)
if i != len(kvs)-1 {
rsp.Status.Code = gremlin.StatusPartialContent
} else {
rsp.Status.Code = gremlin.StatusSuccess
}
err = conn.WriteResponse(&rsp)
require.NoError(t, err)
}
})
defer srv.Close()
conn, err := DefaultDialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
rsp, err := conn.Execute(context.Background(), gremlin.NewEvalRequest("g.E()"))
assert.NoError(t, err)
var result []kv
err = graphson.Unmarshal(rsp.Result.Data, &result)
require.NoError(t, err)
assert.Equal(t, kvs, result)
}
func TestAuthentication(t *testing.T) {
user, pass := "username", "password"
srv := serve(func(conn conn) {
req, err := conn.ReadRequest()
require.NoError(t, err)
rsp := gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusAuthenticate
err = conn.WriteResponse(&rsp)
require.NoError(t, err)
areq, err := conn.ReadRequest()
require.NoError(t, err)
var acreds gremlin.Credentials
err = acreds.UnmarshalText([]byte(areq.Arguments["sasl"].(string)))
assert.NoError(t, err)
areq.Arguments["sasl"] = acreds
assert.Equal(t, gremlin.NewAuthRequest(req.RequestID, user, pass), areq)
rsp = gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusNoContent
err = conn.WriteResponse(&rsp)
require.NoError(t, err)
})
defer srv.Close()
dialer := *DefaultDialer
dialer.user = user
dialer.pass = pass
client, err := dialer.Dial("ws://" + srv.Listener.Addr().String())
require.NoError(t, err)
defer client.Close()
_, err = client.Execute(context.Background(), gremlin.NewEvalRequest("g.E().drop()"))
assert.NoError(t, err)
}

View File

@@ -0,0 +1,64 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ocgremlin
import (
"context"
"entgo.io/ent/dialect/gremlin"
"go.opencensus.io/trace"
)
// Transport is an gremlin.RoundTripper that instruments all outgoing requests with
// OpenCensus stats and tracing.
type Transport struct {
// Base is a wrapped gremlin.RoundTripper that does the actual requests.
Base gremlin.RoundTripper
// StartOptions are applied to the span started by this Transport around each
// request.
//
// StartOptions.SpanKind will always be set to trace.SpanKindClient
// for spans started by this transport.
StartOptions trace.StartOptions
// GetStartOptions allows to set start options per request. If set,
// StartOptions is going to be ignored.
GetStartOptions func(context.Context, *gremlin.Request) trace.StartOptions
// NameFromRequest holds the function to use for generating the span name
// from the information found in the outgoing Gremlin Request. By default the
// name equals the URL Path.
FormatSpanName func(context.Context, *gremlin.Request) string
// WithQuery, if set to true, will enable recording of gremlin queries in spans.
// Only allow this if it is safe to have queries recorded with respect to
// security.
WithQuery bool
}
// RoundTrip implements gremlin.RoundTripper, delegating to Base and recording stats and traces for the request.
func (t *Transport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) {
spanNameFormatter := t.FormatSpanName
if spanNameFormatter == nil {
spanNameFormatter = func(context.Context, *gremlin.Request) string {
return "gremlin:traversal"
}
}
startOpts := t.StartOptions
if t.GetStartOptions != nil {
startOpts = t.GetStartOptions(ctx, req)
}
var rt gremlin.RoundTripper = &traceTransport{
base: t.Base,
formatSpanName: spanNameFormatter,
startOptions: startOpts,
withQuery: t.WithQuery,
}
rt = statsTransport{rt}
return rt.RoundTrip(ctx, req)
}

View File

@@ -0,0 +1,74 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ocgremlin
import (
"context"
"errors"
"testing"
"entgo.io/ent/dialect/gremlin"
"github.com/stretchr/testify/mock"
"go.opencensus.io/trace"
)
type mockExporter struct {
mock.Mock
}
func (e *mockExporter) ExportSpan(s *trace.SpanData) {
e.Called(s)
}
func TestTransportOptions(t *testing.T) {
tests := []struct {
name string
spanName string
wantName string
}{
{
name: "Default formatter",
wantName: "gremlin:traversal",
},
{
name: "Custom formatter",
spanName: "tester",
wantName: "tester",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
var exporter mockExporter
exporter.On(
"ExportSpan",
mock.MatchedBy(func(s *trace.SpanData) bool { return s.Name == tt.wantName })).
Once()
defer exporter.AssertExpectations(t)
trace.RegisterExporter(&exporter)
defer trace.UnregisterExporter(&exporter)
transport := &mockTransport{}
transport.On("RoundTrip", mock.Anything, mock.Anything).
Return(nil, errors.New("noop")).
Once()
defer transport.AssertExpectations(t)
rt := &Transport{
Base: transport,
GetStartOptions: func(context.Context, *gremlin.Request) trace.StartOptions {
return trace.StartOptions{Sampler: trace.AlwaysSample()}
},
}
if tt.spanName != "" {
rt.FormatSpanName = func(context.Context, *gremlin.Request) string {
return tt.spanName
}
}
_, _ = rt.RoundTrip(context.Background(), gremlin.NewEvalRequest("g.E()"))
})
}
}

View File

@@ -0,0 +1,117 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ocgremlin
import (
"context"
"strconv"
"time"
"entgo.io/ent/dialect/gremlin"
"go.opencensus.io/stats"
"go.opencensus.io/stats/view"
"go.opencensus.io/tag"
)
// The following measures are supported for use in custom views.
var (
RequestCount = stats.Int64(
"gremlin/request_count",
"Number of Gremlin requests started",
stats.UnitDimensionless,
)
ResponseBytes = stats.Int64(
"gremlin/response_bytes",
"Total number of bytes in response data",
stats.UnitBytes,
)
RoundTripLatency = stats.Float64(
"gremlin/roundtrip_latency",
"End-to-end latency",
stats.UnitMilliseconds,
)
)
// The following tags are applied to stats recorded by this package.
var (
// StatusCode is the numeric Gremlin response status code,
// or "error" if a transport error occurred and no status code was read.
StatusCode, _ = tag.NewKey("gremlin_status_code")
)
// Default distributions used by views in this package.
var (
DefaultSizeDistribution = view.Distribution(32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576)
DefaultLatencyDistribution = view.Distribution(1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130, 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000)
)
// Package ocgremlin provides some convenience views for measures.
// You still need to register these views for data to actually be collected.
var (
RequestCountView = &view.View{
Name: "gremlin/request_count",
Measure: RequestCount,
Aggregation: view.Count(),
Description: "Count of Gremlin requests started",
}
ResponseCountView = &view.View{
Name: "gremlin/response_count",
Measure: RoundTripLatency,
Aggregation: view.Count(),
Description: "Count of responses received, by response status",
TagKeys: []tag.Key{StatusCode},
}
ResponseBytesView = &view.View{
Name: "gremlin/response_bytes",
Measure: ResponseBytes,
Aggregation: DefaultSizeDistribution,
Description: "Total number of bytes in response data",
}
RoundTripLatencyView = &view.View{
Name: "gremlin/roundtrip_latency",
Measure: RoundTripLatency,
Aggregation: DefaultLatencyDistribution,
Description: "End-to-end latency, by response code",
TagKeys: []tag.Key{StatusCode},
}
)
// Views are the default views provided by this package.
func Views() []*view.View {
return []*view.View{
RequestCountView,
ResponseCountView,
ResponseBytesView,
RoundTripLatencyView,
}
}
// statsTransport is an gremlin.RoundTripper that collects stats for the outgoing requests.
type statsTransport struct {
base gremlin.RoundTripper
}
func (t statsTransport) RoundTrip(ctx context.Context, req *gremlin.Request) (*gremlin.Response, error) {
stats.Record(ctx, RequestCount.M(1))
start := time.Now()
rsp, err := t.base.RoundTrip(ctx, req)
latency := float64(time.Since(start)) / float64(time.Millisecond)
var (
tags = make([]tag.Mutator, 1)
ms = []stats.Measurement{RoundTripLatency.M(latency)}
)
if err == nil {
tags[0] = tag.Upsert(StatusCode, strconv.Itoa(rsp.Status.Code))
ms = append(ms, ResponseBytes.M(int64(len(rsp.Result.Data))))
} else {
tags[0] = tag.Upsert(StatusCode, "error")
}
_ = stats.RecordWithTags(ctx, tags, ms...)
return rsp, err
}

View File

@@ -0,0 +1,94 @@
// Copyright 2019-present Facebook Inc. All rights reserved.
// This source code is licensed under the Apache 2.0 license found
// in the LICENSE file in the root directory of this source tree.
package ocgremlin
import (
"context"
"strings"
"testing"
"entgo.io/ent/dialect/gremlin"
"entgo.io/ent/dialect/gremlin/encoding/graphson"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.opencensus.io/stats/view"
)
func TestStatsCollection(t *testing.T) {
err := view.Register(
RequestCountView,
ResponseCountView,
ResponseBytesView,
RoundTripLatencyView,
)
require.NoError(t, err)
req := gremlin.NewEvalRequest("g.E()")
rsp := &gremlin.Response{RequestID: req.RequestID}
rsp.Status.Code = gremlin.StatusSuccess
rsp.Result.Data = graphson.RawMessage(
`{"@type": "g:List", "@value": [{"@type": "g:Int32", "@value": 42}]}`,
)
transport := &mockTransport{}
transport.On("RoundTrip", mock.Anything, mock.Anything).
Return(rsp, nil).
Once()
defer transport.AssertExpectations(t)
rt := &statsTransport{transport}
_, _ = rt.RoundTrip(context.Background(), req)
tests := []struct {
name string
expect func(*testing.T, *view.Row)
}{
{
name: "gremlin/request_count",
expect: func(t *testing.T, row *view.Row) {
count, ok := row.Data.(*view.CountData)
require.True(t, ok)
assert.Equal(t, int64(1), count.Value)
},
},
{
name: "gremlin/response_count",
expect: func(t *testing.T, row *view.Row) {
count, ok := row.Data.(*view.CountData)
require.True(t, ok)
assert.Equal(t, int64(1), count.Value)
},
},
{
name: "gremlin/response_bytes",
expect: func(t *testing.T, row *view.Row) {
data, ok := row.Data.(*view.DistributionData)
require.True(t, ok)
assert.EqualValues(t, len(rsp.Result.Data), data.Sum())
},
},
{
name: "gremlin/roundtrip_latency",
expect: func(t *testing.T, row *view.Row) {
data, ok := row.Data.(*view.DistributionData)
require.True(t, ok)
assert.NotZero(t, data.Sum())
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name[strings.Index(tt.name, "/")+1:], func(t *testing.T) {
v := view.Find(tt.name)
assert.NotNil(t, v)
rows, err := view.RetrieveData(tt.name)
require.NoError(t, err)
require.Len(t, rows, 1)
tt.expect(t, rows[0])
})
}
}

Some files were not shown because too many files have changed in this diff Show More